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.
23 comments:
Nice article, it's possible to have this with secure role? That mean when you go at first time to the dynamic data website you see only one menu the "Home" by default and after a login you will see the different table attached to the role. It's possible?
Hi Michel, I'm sure it's possible, and will hopfully cover this in an article in the future
Steve :)
I was able to bring up the tabbed version, but when I clicked one of the drop-down menu items, the application couldn't find DropDownList1 and one or more other controls. I was connected to Northwind on the network.
I would like to see a version of this with rounded corners tabbed menu with the ability to style and possibly use images.
The question is does the sample work for you with Northwind local?
Steve
Hi, Steve. Do I need to create an App_Data folder and get a copy of Northwind.mdf into it?
What should my top-level directory be?
The solution appears to have three top-level directories.
I usually just mount them on Local install of SQL server, that way I don't endup with hundreds of copies of Northwind on my drive :)
the connection string server name is usually set to . which is the locat server.
Steve
How Can we use this with jQuery menus?
Regards,
Nikunj Dhawan
I suppose you could, but I don't know of any jQuery menu that would work with a SiteMap data source. You could certanly use the main logic to build some xml for the jQuery menu.
Steve :)
I have problem with the sample code DDMenu and de error was: Invalid column name 'FullName'.
Regards,
Adrian
Hi Adrian, is that with a sample as is or added into your own project, can you give me steps to repro? you can e-mail at the e-mail address at the top of the page.
Steve :D
I ended up having the same issue regarding FullName. It looks like your model is based off of a Northwind DB instance that has a computed column on Employees called FullName but I was not able to find any Northwind install script that included this field.
I ended up defining it myself and the error went away. Interestingly enough though the column doesn't appear on the List or Edit pages. I'm guessing the default behavior is not to display computed columns?
Thanks,
Matt
Hi Guys, sorry about that, it was a sample I was doing for someone else and the DB got mixed into that sample sorry.
Just refresh the model from standard Northwind and I'll fix the sample and reupload in the morning.
:(
Steve
This doesn't work. I downloaded the .net 4 tabbed version and changed the datasource and metadata to fit with my db and thats it, then it stopped working.
Hi There you may have some other issue going on I have added this to several other sites and it's working fine. You have probably not follow the article above and have missed something out e.g. metadata.
Steve :)
Following sitemap file is throwing an exception
can you please check.
Hi there, I don;t see your sitemap but I suspect you have some elements below the first level, this version does not support that sorry.
Steve
P.S. I have a newer version in the works that does but I haven't had the time to publish it yet.
Steve could you possibly email me the newer version of this?
if you drop me an e-mail (my e-mail is in the top righ corner of the site) I will try to send you a copy over Christmas.
Steve
Hi Steve,
I am a big fan of your work and all the great thing you did for DD. I'm also a big fan of DD as I think it solves a lot of problems and saves a ton of work!
Anyway I noticed in this sitemap provider that if you add a displayname for a table the meta sitemap provider will break.
like:
[IncludeInDomainService]
[Menu(Show = true, Order = 0)]
[TableName("My user")] // >> if you add a display name like this then the sitemap provider will fail
public class Users : Base
{
public string UserName { get; set; }
}
Any ideas?
Hi there "TableName" is not the same as display name it actually changes the table's name you should use DisplayName I will look into this with the next iteration of SiteMapProvider but I suspect the issues is the changes table name :)
Steve
Hi Mr Naughton,
I have a interesting situation and I think you can help me. I need to build a breadcrumb based on the data coming from a Sql stored proc. Is it possible, if yes whats the best approach. I have .net 2.0 and VS 2005.
Thanks
The bread crumb control uses a tree structure for it's data, my sitemap provider would work with it fine but it sounds like you are not using Dynamic Data?
Of course it's possible to create a bread crumb, I would suggest using a user control.
I currently use VS2010 and .Net 4.
Steve
The control is working really fine but I've manually added some extra nodes in the sitemap file(DynamicData.sitemap) and they don't appear in the main menu. Any idea why?
Post a Comment