There was a post on the DD Forum a little while ago (Last week when I wrote this article) and it’s the issue of menus and site navigation in Dynamic Data, I’ve had several go’s at this on several projects and although I have had workable solutions I felt I was reinventing the wheel each time. So after the post politely asking for me to go into it a little deeper I made some suggestions on the forum that afterwards I felt were still going about this in the wrong way. What I felt we needed was our own SiteMapDataSourceand of course I was wrong, I discovered after a little research was I needed a SiteMapProvider which would plug into the SiteMapDataSource via the web.config allowing me to provide my own solution to navigation and menus. This is very similar the MembersipProvider and RolesProvider which makes this really cool
So now we will have a solution that is easy to deploy and maintain, so in good Blue Peter style here are the things we are going to need:
The Menu attribute
The SiteMapProvider
Updates to the Web.Config
Metadata
And a Menu and SiteMapPath (bread crumb) controls
web.sitemap file for custom paths (non Model pages)The MenuAttribute
At first I hade some code like Listing 1 here I generated the code in the page (site.master) and bound it to an XmlDataSource this worked after a fashion and produced the menus I wanted but they were organised by the DB structure not really what I wanted.
protected void Page_Load(object sender, EventArgs e) { //System.Collections.IList visibleTables = MetaModel.Default.VisibleTables; System.Collections.IList visibleTables = MetaModel.Default.VisibleTables; if (visibleTables.Count == 0) { throw new InvalidOperationException("There are no accessible tables. Make.."); } var root = new XElement("home"); foreach (var table in MetaModel.Default.VisibleTables) { root.Add(GetChildren(table)); } XmlDataSource1.Data = root.ToString(); Menu1.DataSource = XmlDataSource1; Menu1.Orientation = Orientation.Horizontal; Menu1.DataBind(); } private XElement GetChildren(MetaTable parent) { XElement children = new XElement("Menu", new XAttribute("title", parent.DisplayName), new XAttribute("url", parent.ListActionPath), from c in parent.Columns where c.GetType() == typeof(MetaChildrenColumn) && ((MetaChildrenColumn)c).ChildTable.Name != parent.Name orderby c.DisplayName select GetChildren(((MetaChildrenColumn)c).ChildTable)); return children; }
Listing 1 – old Menu generating code
I suppose I could have used a sitemap file (“Web.sitemap” by default) I decided after a few stunted attempts with client sites that although this gave you the control it was tedious and boring especially with an constantly changing site.
So my final design was to have an attribute to apply to the metadata that would allow me to impose the site structure I wanted in the same place I was applying the all my other attributes (keep it all in one place is what I say then when you go looking it’s easy to find.)
/// <summary> /// Attribute to identify which column to use as a /// parent column for the child column to depend upon /// </summary> [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class MenuAttribute : Attribute, IComparable { public static MenuAttribute Default = new MenuAttribute(); /// <summary> /// Gets or sets the name of the menu. /// </summary> /// <value>The name of the menu.</value> public String Name { get; private set; } /// <summary> /// Gets or sets the parent. /// </summary> /// <value>The parent.</value> public String Parent { get; set; } /// <summary> /// Gets or sets a value indicating whether this <see cref="MenuAttribute"/> is show. /// </summary> /// <value><c>true</c> if show; otherwise, <c>false</c>.</value> public Boolean Show { get; set; } /// <summary> /// Gets or sets the order. /// </summary> /// <value>The order.</value> public int Order { get; set; } /// <summary> /// Gets or sets the image path. /// </summary> /// <value>The image path.</value> public String ImagePath { get; set; } /// <summary> /// Initializes a new instance of the <see cref="MenuAttribute"/> class. /// </summary> public MenuAttribute() { Name = String.Empty; Parent = String.Empty; ImagePath = String.Empty; Show = false; Order = 0; } /// <summary> /// Initializes a new instance of the <see cref="MenuAttribute"/> class. /// </summary> /// <param name="menuName">Name of the menu.</param> public MenuAttribute(String menuName) { Name = menuName; } #region IComparable Members public int CompareTo(object obj) { return Order - ((MenuAttribute)obj).Order; } #endregion }
Listing 2 – the Menu attribute
I decided I wanted to be able to specify which table are in the root of the menu which are children and of which table and also if a table is shown or not, see Listing 2. to make a table appear in the root of the menu structure you need to Parent to be empty and Show must be true* and Children must Have the Parent set to the menu Name if specified or Table Name and be set to show.
/// <summary> /// Get the attribute or a default instance of the attribute /// if the Table attribute do not contain the attribute /// </summary> /// <typeparam name="T">Attribute type</typeparam> /// <param name="table"> /// Table to search for the attribute on. /// </param> /// <returns> /// The found attribute or a default /// instance of the attribute of type T /// </returns> public static T GetAttributeOrDefault<T>(this MetaTable table) where T : Attribute, new() { return table.Attributes.OfType<T>().DefaultIfEmpty(new T()).FirstOrDefault(); }
Listing 3 GetAttributeOrDefault extension method.
I also threw in an Order property so the menus could be sorted on both the root and sub menus and an ImagePath property so that you can specify an image. Name is optional and in not specified it will user the DisplayName of the table.
The SiteMapProvider
This will go away and read the MetaModel and produce the site map from that instead of a web.sitemap file.
#region Constants private const String PROVIDER_NAME = "MetaDataSiteMapProvider"; private const string CACHE_DEPENDENCY_NAME = "MetaDataSiteMapCacheDependency"; private const String DESCRIPTION = "description"; private const String SITE_MAP_FILE = "SiteMapFile"; private const String DEFAULT_SITE_MAP_FILE = "DynamicData.sitemap"; #endregion #region Fields private readonly object objLock = new object(); private SiteMapNode rootNode; private String _siteMapFile; #endregion #region Properties private MetaModel _model; public MetaModel Model { get { if (_model == null) _model = MetaModel.Default; return this._model; } set { this._model = value; } } #endregion #region Initializartion public override void Initialize(string name, NameValueCollection config) { if (config == null) throw new ArgumentNullException("config"); // get provider name if (String.IsNullOrEmpty(name)) name = "MetaDataSiteMapProvider"; // get custom mappings if (!string.IsNullOrEmpty(config[SITE_MAP_FILE])) _siteMapFile = config[SITE_MAP_FILE]; else _siteMapFile = DEFAULT_SITE_MAP_FILE; // get description if (string.IsNullOrEmpty(config[DESCRIPTION])) { config.Remove(DESCRIPTION); config.Add(DESCRIPTION, "MetaData Site Map Provider for Dynamic Data"); } base.Initialize(name, config); } #endregion /// <summary> /// Loads the site map information from /// persistent storage and builds it in memory. /// </summary> /// <returns> /// The root <see cref="T:System.Web.SiteMapNode"/> /// of the site map navigation structure. /// </returns> public override SiteMapNode BuildSiteMap() { // need to make sure we start with a clean slate if (rootNode != null) return rootNode; lock (objLock) { rootNode = new SiteMapNode(this, "root"); // get tables for var tables = from t in Model.Tables where String.IsNullOrEmpty(t.GetAttributeOrDefault<MenuAttribute>().Parent) && t.GetAttributeOrDefault<MenuAttribute>().Show // sort first by menu order then by name. orderby t.GetAttributeOrDefault<MenuAttribute>(), t.DisplayName select t; // get external content. var sitemapFile = HttpContext.Current.Server.MapPath("~/" + _siteMapFile); if (File.Exists(sitemapFile)) { var sitemap = XDocument.Load(sitemapFile); var elements = sitemap.Descendants().Descendants().Descendants(); foreach (var element in elements) { var provider = element.Attributes().FirstOrDefault(a => a.Name == "provider"); if (provider != null && !String.IsNullOrEmpty(provider.Value) && provider.Value == PROVIDER_NAME) { foreach (var table in tables) { SetChildren(table, rootNode); } } else { SetXmlChildren(element, rootNode); } } } else { foreach (var table in tables) { SetChildren(table, rootNode); } } } // not sure if this is needed no real explination // was given in the samples I've seen. HttpRuntime.Cache.Insert(CACHE_DEPENDENCY_NAME, new object()); return rootNode; } private void SetXmlChildren(XElement element, SiteMapNode parentNode) { var url = element.Attributes().First(a => a.Name == "url"); var node = new SiteMapNode(this, url.Value, url.Value); foreach (var attribute in element.Attributes()) { switch (attribute.Name.ToString()) { case "description": node.Description = attribute.Value; break; case "resourceKey": node.ResourceKey = attribute.Value; break; case "roles": node.Roles = attribute.Value.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries); break; case "title": node.Title = attribute.Value; break; case "siteMapFile": case "securityTrimmingEnabled": case "provider": case "url": default: break; } } AddNode(node, parentNode); if (element.HasElements) { foreach (var childElement in element.Descendants()) { SetXmlChildren(childElement, node); } } } /// <summary> /// Sets the children nodes of the current node. /// </summary> /// <param name="parentTable">The parent table.</param> /// <param name="parentNode">The parent node.</param> private void SetChildren(MetaTable parentTable, SiteMapNode parentNode) { String imageUrl = String.Empty; var description = parentTable.GetAttribute<DescriptionAttribute>(); var menuAttribute = parentTable.GetAttribute<MenuAttribute>(); if (menuAttribute != null) imageUrl = menuAttribute.ImagePath; // get extra attributes, I'm just going to // use the ImageUrl for use in menus etc. NameValueCollection attributes = null; if (String.IsNullOrEmpty(imageUrl)) { attributes = new NameValueCollection(); attributes.Add("ImageUrl", imageUrl); } // get the title var menuTitle = !String.IsNullOrEmpty(menuAttribute.Name) ? menuAttribute.Name : parentTable.DisplayName; // get the description if attribute has // been applied or DisplayName if not. var menuDescription = description != null ? description.Description : parentTable.DisplayName; // note resource keys are not used I'm // not bothering with localization. var node = new SiteMapNode( this, // note Key and Url must match // for slected menus to showup parentTable.ListActionPath, parentTable.ListActionPath, menuTitle, menuDescription, null, attributes, null, ""); // we can't add two nodes with same URL an // InvalidOperationException with be thrown if we do AddNode(node, parentNode); // get the children nodes of this node. var tables = from t in Model.Tables where !String.IsNullOrEmpty(t.GetAttributeOrDefault<MenuAttribute>().Parent) && t.GetAttributeOrDefault<MenuAttribute>().Parent == menuTitle && t.GetAttributeOrDefault<MenuAttribute>().Show orderby t.GetAttributeOrDefault<MenuAttribute>(), t.DisplayName select t; // add children of current node foreach (var t in tables) { // call this method recursively. SetChildren(t, node); } } /// <summary> /// Retrieves the root node of all the nodes that /// are currently managed by the current provider. /// </summary> /// <returns> /// A <see cref="T:System.Web.SiteMapNode"/> that /// represents the root node of the set of nodes /// that the current provider manages. /// </returns> protected override SiteMapNode GetRootNodeCore() { BuildSiteMap(); return rootNode; } /// <summary> /// Removes all elements in the collections of /// child and parent site map nodes that the /// <see cref="T:System.Web.StaticSiteMapProvider"/> /// tracks as part of its state. /// </summary> protected override void Clear() { lock (objLock) { this.rootNode = null; base.Clear(); } }
Listing – 4 the SiteMapProvider
With this added and an entry for the provider set to "MetaDataSiteMapProvider" the standard nodes are added and when the node (and “there can be only one”) the Metadata nodes are added. See Figure 1.
In the SiteMapProvider Listing 4 the two main methods are BuildSiteMap and SetChildren; BuildSiteMap starts by building the root menu and then calls SetChildren for each menu entry in the root to create the sub menus entries (and their sub menus as it calls it’s self recursively).
<?xml version="1.0" encoding="utf-8" ?> <siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0" > <siteMapNode url="" title="" description=""> <siteMapNode url="~/Default.aspx" title="Home" description="Home page" roles="*" /> <siteMapNode provider="MetaDataSiteMapProvider" /> <siteMapNode url="~/Admin.aspx" title="Admin" description="Site Administration" roles="*" /> </siteMapNode> </siteMap>
Listing 5 – the DynamicData.sitemap file
To make this work we need an entry in the web.config file see Listing 5 this entry live inside the <system.web> tag.
<siteMap defaultProvider="MetaDataSiteMapProvider"> <providers> <add name="MetaDataSiteMapProvider" Description="MetaData Site Map Provider for Dynamic Data" type="NotAClue.Web.DynamicData.MetaDataSiteMapProvider, NotAClue.Web.DynamicData"/> </providers> </siteMap>
Listing 6 – SiteMap entry in web.config.
Adding Menu and SiteMapPath controls to Site.Master
Now we need to add a Menu or TreeView control to the site.master page (I am also going to add a SiteMapPath control as a bread crumb)
<div> <asp:Menu ID="Menu1" runat="server" BackColor="#E3EAEB" DataSourceID="SiteMapDataSource1" DynamicHorizontalOffset="2" Font-Names="Verdana" Font-Size="8pt" ForeColor="#666666" Orientation="Horizontal" StaticSubMenuIndent="10px" StaticDisplayLevels="2"> <StaticSelectedStyle BackColor="#1C5E55" /> <StaticMenuItemStyle HorizontalPadding="5px" VerticalPadding="2px" /> <DynamicHoverStyle BackColor="#666666" ForeColor="White" /> <DynamicMenuStyle BackColor="#E3EAEB" /> <DynamicSelectedStyle BackColor="#1C5E55" /> <DynamicMenuItemStyle HorizontalPadding="5px" VerticalPadding="2px" /> <StaticHoverStyle BackColor="#666666" ForeColor="White" /> </asp:Menu> <asp:SiteMapDataSource ID="SiteMapDataSource1" runat="server" ShowStartingNode="False" /> </div> <div> <asp:SiteMapPath ID="SiteMapPath1" runat="server" Font-Names="Verdana" Font-Size="8pt" PathSeparator=" > "> <PathSeparatorStyle Font-Bold="True" ForeColor="#1C5E55" /> <CurrentNodeStyle ForeColor="#333333" /> <NodeStyle Font-Bold="True" ForeColor="#666666" /> <RootNodeStyle Font-Bold="True" ForeColor="#1C5E55" /> </asp:SiteMapPath> </div>
Listing 7 – adding Menu and SiteMapPath controls to site.master.
In Listing 6 I’ve just dragged and dropped ASP.Net Menu and SiteMapPath web controls to the page an configured the Menu by adding a new SiteMapDataSource control.
Then I have used the Auto Format… option to give both the Menu and SiteMapPath some styling.
Sample Metadata using Northwind
[Menu(Show = true, Parent = "Products", Order = 0)] partial class Category { } [Menu(Show = true, Parent = "Employees", Order = 0)] partial class Territory { } partial class CustomerCustomerDemo { } partial class CustomerDemographic { } [Menu("Customers", Show = true, Order = 0)] partial class Customer { } [Menu("Employees", Show = true, Order = 2)] partial class Employee { } partial class EmployeeTerritory { } [Menu("Order Details", Show = true, Parent = "Orders", Order = 0)] partial class Order_Detail { } [Menu("Orders", Show = true, Order = 1)] partial class Order { } [Menu("Products", Show = true, Order = 3)] partial class Product { } [Menu("Regions", Show = true, Parent = "Territories", Order = 0)] partial class Region { } [Menu("Shippers", Show = true, Parent = "Orders", Order = 0)] partial class Shipper { } [Menu("Suppliers", Show = true, Parent = "Products", Order = 1)] partial class Supplier { }
Listing 8 – sample metadata.
The Result
Figure 1 – Dynamic Data Menus and bread crumb (SiteMapPath)
Downloads
Left is the ASP.Net 3.5 SP1 and right is the .Net 4.0 version & now with the new Tabbed Menu from the new ASP.Net project template.