Sunday, 28 February 2010

SiteMapProvider for Dynamic Data (.Net 3.5 SP1 and .Net 4.0) (UPDATED)

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 SiteMapDataSourceBlushingand 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 coolBig Grin

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.

* This is because I will be using my GetAttributeOrDefault extension method see Listing 3
/// <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

Updated: I have made a few changes in Listing 4 I found that I was still missing something mainly an easy way to add custom page mappings (SiteMapNodes) so I have added the facility to use a web.sitemap my default file name is DyanmicData.sitemap.

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.

Note: You could also set the SiteMapProvider to our MetaDataSiteMapProvider this is not really required as we have set as default in the web.config.

Then I have used the Auto Format… option to give both the Menu and SiteMapPath some styling.

Note: In ASP.Net 4.0 the Menu Control like many other controls now boasts cleaner mark-up; in the Menus case it now uses un-ordered lists to produce the menu.

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.

Note: I’ve trimmed the metadata right down removing the metadata classes to keep it brief.

The Result

Dynamic Data Menus and bread crumb (SiteMapPath) Asp.Net 4.0 Tabbed Menu

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:

Michel S. said...

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?

Steve said...

Hi Michel, I'm sure it's possible, and will hopfully cover this in an article in the future

Steve :)

Jim said...

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.

Steve said...

The question is does the sample work for you with Northwind local?

Steve

Jim said...

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.

Steve said...

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

Anonymous said...

How Can we use this with jQuery menus?

Regards,
Nikunj Dhawan

Steve said...

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 :)

Anonymous said...

I have problem with the sample code DDMenu and de error was: Invalid column name 'FullName'.

Regards,
Adrian

Steve said...

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

Anonymous said...

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

Steve said...

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

Anonymous said...

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.

Steve said...

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 :)

Anonymous said...

Following sitemap file is throwing an exception
can you please check.

Steve said...

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.

Don said...

Steve could you possibly email me the newer version of this?

Steve said...

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

Anonymous said...

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?

Steve said...

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

Amar said...

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

Steve said...

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

KOsmix said...

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?