dantey89
dantey89

Reputation: 2287

asp net MVC 5 SiteMap - build breadcrumb to another controller

I developing breadcrumbs for my asp net mvc e-commerce. I have a controller for my categories. It looks like this:

 public class CategoryController : AppController
    {

    public ActionResult Index(string cat1, string cat2, string cat3, int? page)
        {

... some code

       // build breadcrumbs from parent cats
        int indexer = 0;
        foreach(var item in parCategories) //parCategories - list of parent categories
        {
            string currCatIndex = new StringBuilder().AppendFormat("category{0}", indexer + 1).ToString(); //+2 cause arr index begins from 0
            var currNode = SiteMaps.Current.FindSiteMapNodeFromKey(currCatIndex);           
            currNode.Title= parCategories.ElementAt(indexer).Name;
            indexer++;
        }

        string finalCatIndex = new StringBuilder().AppendFormat("category{0}", CategoryDepth + 1).ToString();
        var node = SiteMaps.Current.FindSiteMapNodeFromKey(finalCatIndex);
        node.Title = CurrCategory.Category.Name;

       //show View
        }
}

A'm showing list of products. If user opens product, request performing with another controller:

  public class ProductController : AppController
    {
        // GET: Product
        public ActionResult Index(string slug)
        {   
           // find product by slug and show it
        }

Here is my rout config:

 routes.MapRoute(
                name: "Category",
                url: "Category/{cat1}/{cat2}/{cat3}",
                defaults: new { controller = "Category", action = "Index", cat1 = UrlParameter.Optional, cat2= UrlParameter.Optional, cat3 = UrlParameter.Optional }    
            );

            routes.MapRoute(
               name: "Product",
               url: "Product/{slug}",
               defaults: new { controller = "Product", action = "Index", slug = UrlParameter.Optional}
           );

And sitemap for categories (works perfect):

 <mvcSiteMapNode title="Категории" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1" key="category1">
      <mvcSiteMapNode title="Категории2" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1;cat2" key="category2">
        <mvcSiteMapNode title="Категории3" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1;cat2;cat3" key="category3" />
      </mvcSiteMapNode>
    </mvcSiteMapNode>

But I dont know how to build bredcrumbs for product like this:

Home>cat1>Product_name
Home>cat1>cat2>Product_name
Home>cat1>cat2>cat3>Product_name

What I tried:

This sitemap:

 <mvcSiteMapNode title="Категории" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1" key="category1">
      <mvcSiteMapNode title="Prod" controller="Product" action="Index" route="Product" preservedRouteParameters="slug" key="prod1" />
      <mvcSiteMapNode title="Категории2" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1;cat2" key="category2">
        <mvcSiteMapNode title="Prod" controller="Product" action="Index" route="Product" preservedRouteParameters="slug" key="prod2" />
        <mvcSiteMapNode title="Категории3" controller="Category" action="Index" route="Category" preservedRouteParameters="cat1;cat2;cat3" key="category3" >
          <mvcSiteMapNode title="Prod" controller="Product" action="Index" route="Product" preservedRouteParameters="slug" key="prod3" />
        </mvcSiteMapNode>
        </mvcSiteMapNode>
    </mvcSiteMapNode> 

And also I tried custom DynamicNodeProvider

<mvcSiteMapNode title="Товар" controller="Product" action="Index" route="Product" preservedRouteParameters="slug" key="prodDyn" dynamicNodeProvider="FlatCable_site.Libs.Mvc.ProductNodeProvider, FlatCable_site" />

And provider:

  public class ProductNodeProvider : DynamicNodeProviderBase
        {
            public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
            {

    // tried to get action parameter (slug) and get product by slug, then build category hierarchy but it doesn't passing
    // also this code calls on each page, not only on *site.com/Product/test_prod*

}

Upvotes: 1

Views: 4308

Answers (1)

NightOwl888
NightOwl888

Reputation: 56869

MvcSiteMapProvider already does most of the work for you. It keeps a cache of the hierarchical relationship between the nodes and also automatically looks up the current node on each request.

The only things you need to do are provide the node hierarchy (once per application start) and use the HTML helper for breadcrumbs, namely @Html.MvcSiteMap().SiteMapPath(). You can also optionally customize the URLs any way you like using routing.

Since you are likely dealing with database-driven data, you should use DynamicNodeProvider so new data will be automatically available in the SiteMap after it is added to the database.

Database

First of all, your database should keep track of the parent-child relationship between categories. You can do that with a self-joining table.

| CategoryID  | ParentCategoryID  | Name           | UrlSegment     |
|-------------|-------------------|----------------|----------------|
| 1           | null              | Категории      | category-1     |
| 2           | 1                 | Категории2     | category-2     |
| 3           | 2                 | Категории3     | category-3     |

Depending on where you put the categories in your web site, null should represent the parent node (usually it be the home page or a top-level category list page).

Then your products should be categorized. This gets more complicated if there is a many-to-many relationship between category and product because each node should have its own unique URL (even if it is just another link to the same product page). I won't go into details here, but using the canonical tag helper in conjunction with custom routing (possibly data-driven URLs) is the recommended approach. It is natural to add the category to the beginning of the product URL (which I show below) so you will have unique URLs for each category view of the product. Then you should add an additional flag in the database to keep track of the "primary" category, which can then be used to set the canonical key.

For the rest of this example, I will assume the product to category relationship is 1-to-1, but that is not how most e-commerce is done these days.

| ProductID   | CategoryID | Name           | UrlSegment     |
|-------------|------------|----------------|----------------|
| 1           | 3          | Prod1          | product-1      |
| 2           | 1          | Prod2          | product-2      |
| 3           | 2          | Prod3          | product-3      |

Controllers

Next, the controllers are built to supply the dynamic category and product information. MvcSiteMapProvider uses the controller and action name.

Note that the exact way you get the product in your application depends on your design. This example uses CQS.

public class CategoryController : Controller
{
    private readonly IQueryProcessor queryProcessor;

    public CategoryController(IQueryProcessor queryProcessor)
    {
        if (queryProcessor == null)
            throw new ArgumentNullException("queryProcessor");

        this.queryProcessor = queryProcessor;
    }

    public ActionResult Index(int id)
    {
        var categoryDetails = this.queryProcessor.Execute(new GetCategoryDetailsQuery
        {
            CategoryId = id
        });

        return View(categoryDetails);
    }
}


public class ProductController : Controller
{
    private readonly IQueryProcessor queryProcessor;

    public ProductController(IQueryProcessor queryProcessor)
    {
        if (queryProcessor == null)
            throw new ArgumentNullException("queryProcessor");

        this.queryProcessor = queryProcessor;
    }

    public ActionResult Index(int id)
    {
        var productDetails = this.queryProcessor.Execute(new GetProductDetailsDataQuery
        {
            ProductId = id
        });

        return View(productDetails);
    }
}

Dynamic Node Providers

For maintenance purposes, using separate category and product node providers may make things easier but it is not strictly necessary. In fact, you could provide all of your nodes with a single dynamic node provider.

public class CategoryDynamicNodeProvider : DynamicNodeProviderBase
{
    public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
    {
        var result = new List<DynamicNode>();

        using (var db = new MyEntities())
        {
            // Create a node for each category
            foreach (var category in db.Categories)
            {
                DynamicNode dynamicNode = new DynamicNode();

                // Key mapping
                dynamicNode.Key = "Category_" + category.CategoryID;

                // NOTE: parent category is defined as int?, so we need to check
                // whether it has a value. Note that you could use 0 instead if you want.
                dynamicNode.ParentKey = category.ParentCategoryID.HasValue ? "Category_" + category.ParentCategoryID.Value : "Home";

                // Add route values
                dynamicNode.Controller = "Category";
                dynamicNode.Action = "Index";
                dynamicNode.RouteValues.Add("id", category.CategoryID);

                // Set title
                dynamicNode.Title = category.Name;

                result.Add(dynamicNode);
            }
        }

        return result;
    }
}

public class ProductDynamicNodeProvider : DynamicNodeProviderBase
{
    public override IEnumerable<DynamicNode> GetDynamicNodeCollection(ISiteMapNode node)
    {
        var result = new List<DynamicNode>();

        using (var db = new MyEntities())
        {
            // Create a node for each product
            foreach (var product in db.Products)
            {
                DynamicNode dynamicNode = new DynamicNode();

                // Key mapping
                dynamicNode.Key = "Product_" + product.ProductID;
                dynamicNode.ParentKey = "Category_" + product.CategoryID;

                // Add route values
                dynamicNode.Controller = "Product";
                dynamicNode.Action = "Index";
                dynamicNode.RouteValues.Add("id", product.ProductID);

                // Set title
                dynamicNode.Title = product.Name;

                result.Add(dynamicNode);
            }
        }

        return result;
    }
}

Alternatively, if you use DI you might consider implementing ISiteMapNodeProvider instead of dynamic node provider. It is a lower level abstraction that allows you to provide all of your nodes.

Mvc.sitemap

All that you need in your XML are static pages and dynamic node provider definition nodes. Note that you have already defined the parent-child relationship within the dynamic node providers, so there is no need to do it again here (although you could to make it more clear that products are nested within categories).

<?xml version="1.0" encoding="utf-8" ?>
<mvcSiteMap xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xmlns="http://mvcsitemap.codeplex.com/schemas/MvcSiteMap-File-4.0"
            xsi:schemaLocation="http://mvcsitemap.codeplex.com/schemas/MvcSiteMap-File-4.0 MvcSiteMapSchema.xsd">

    <mvcSiteMapNode title="Home" controller="Home" action="Index">
        <mvcSiteMapNode title="Category Nodes" dynamicNodeProvider="MyNamespace.CategoryDynamicNodeProvider, MyAssembly" />
        <mvcSiteMapNode title="Product Nodes" dynamicNodeProvider="MyNamespace.ProductDynamicNodeProvider, MyAssembly" />
    </mvcSiteMapNode>
</mvcSiteMap>

SiteMapPath

Then it is just a matter of putting the SiteMapPath into your views. The simplest approach is just to add it to your _Layout.cshtml.

<div id="body">
    @RenderSection("featured", required: false)
    <section class="content-wrapper main-content clear-fix">
        @Html.MvcSiteMap().SiteMapPath()
        @RenderBody()
    </section>
</div>

Note that you can edit the templates (or create named templates) in the /Views/Shared/DisplayTemplates/ folder to customize the HTML that is output by the HTML helpers.

Routing

As I mentioned before, I recommend using data-driven routing when making data-driven pages. The primary reason for this is that I am a purist. Routing logic does not belong in the controller, so passing a slug to the controller is a messy solution.

Also, if you have a primary key to URL mapping, it means the routing is only cosmetic as far as the rest of the application is concerned. Keys are what drive the application (and the database) and URLs are what drive MVC. This makes managing URLs external to your application logic.

CachedRoute<TPrimaryKey>

This is an implementation that allows you to map a set of data records to a single controller action. Each record has a separate virtual path (URL) that maps to a specific primary key.

This class is reusable so you can use it for multiple sets of data (typically one class per database table you wish to map).

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

public class CachedRoute<TPrimaryKey> : RouteBase
{
    private readonly string cacheKey;
    private readonly string controller;
    private readonly string action;
    private readonly ICachedRouteDataProvider<TPrimaryKey> dataProvider;
    private readonly IRouteHandler handler;
    private object synclock = new object();

    public CachedRoute(string controller, string action, ICachedRouteDataProvider<TPrimaryKey> dataProvider)
        : this(controller, action, typeof(CachedRoute<TPrimaryKey>).Name + "_GetMap_" + controller + "_" + action, dataProvider, new MvcRouteHandler())
    {
    }

    public CachedRoute(string controller, string action, string cacheKey, ICachedRouteDataProvider<TPrimaryKey> dataProvider, IRouteHandler handler)
    {
        if (string.IsNullOrWhiteSpace(controller))
            throw new ArgumentNullException("controller");
        if (string.IsNullOrWhiteSpace(action))
            throw new ArgumentNullException("action");
        if (string.IsNullOrWhiteSpace(cacheKey))
            throw new ArgumentNullException("cacheKey");
        if (dataProvider == null)
            throw new ArgumentNullException("dataProvider");
        if (handler == null)
            throw new ArgumentNullException("handler");

        this.controller = controller;
        this.action = action;
        this.cacheKey = cacheKey;
        this.dataProvider = dataProvider;
        this.handler = handler;

        // Set Defaults
        CacheTimeoutInSeconds = 900;
    }

    public int CacheTimeoutInSeconds { get; set; }


    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        string requestPath = httpContext.Request.Path;
        if (!string.IsNullOrEmpty(requestPath))
        {
            // Trim the leading and trailing slash
            requestPath = requestPath.Trim('/'); 
        }

        TPrimaryKey id;

        //If this returns false, that means the URI did not match
        if (!this.GetMap(httpContext).TryGetValue(requestPath, out id))
        {
            return null;
        }

        var result = new RouteData(this, new MvcRouteHandler());

        result.Values["controller"] = this.controller;
        result.Values["action"] = this.action;
        result.Values["id"] = id;

        return result;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        TPrimaryKey id;
        object idObj;
        object controller;
        object action;

        if (!values.TryGetValue("id", out idObj))
        {
            return null;
        }

        id = SafeConvert<TPrimaryKey>(idObj);
        values.TryGetValue("controller", out controller);
        values.TryGetValue("action", out action);

        // The logic here should be the inverse of the logic in 
        // GetRouteData(). So, we match the same controller, action, and id.
        // If we had additional route values there, we would take them all 
        // into consideration during this step.
        if (action.Equals(this.action) && controller.Equals(this.controller))
        {
            // The 'OrDefault' case returns the default value of the type you're 
            // iterating over. For value types, it will be a new instance of that type. 
            // Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct), 
            // the 'OrDefault' case will not result in a null-reference exception. 
            // Since TKey here is string, the .Key of that new instance will be null.
            var virtualPath = GetMap(requestContext.HttpContext).FirstOrDefault(x => x.Value.Equals(id)).Key;
            if (!string.IsNullOrEmpty(virtualPath))
            {
                return new VirtualPathData(this, virtualPath);
            }
        }

        return null;
    }

    private IDictionary<string, TPrimaryKey> GetMap(HttpContextBase httpContext)
    {
        IDictionary<string, TPrimaryKey> map;
        var cache = httpContext.Cache;
        map = cache[this.cacheKey] as IDictionary<string, TPrimaryKey>;
        if (map == null)
        {
            lock (synclock)
            {
                map = cache[this.cacheKey] as IDictionary<string, TPrimaryKey>;
                if (map == null)
                {
                    map = this.dataProvider.GetVirtualPathToIdMap(httpContext);
                    cache[this.cacheKey] = map;
                }
            }
        }
        return map;
    }

    private static T SafeConvert<T>(object obj)
    {
        if (typeof(T).Equals(typeof(Guid)))
        {
            if (obj.GetType() == typeof(string))
            {
                return (T)(object)new Guid(obj.ToString());
            }
            return (T)(object)Guid.Empty;
        }
        return (T)Convert.ChangeType(obj, typeof(T));
    }
}

ICachedRouteDataProvider<TPrimaryKey>

This is the extension point where you supply your virtual path to primary key mapping data.

public interface ICachedRouteDataProvider<TPrimaryKey>
{
    IDictionary<string, TPrimaryKey> GetVirtualPathToIdMap(HttpContextBase httpContext);
}

CategoryCachedRouteDataProvider

Here is an implementation of the above interface to provide categories to the CachedRoute.

public class CategoryCachedRouteDataProvider : ICachedRouteDataProvider<int>
{
    private readonly ICategorySlugBuilder categorySlugBuilder;

    public CategoryCachedRouteDataProvider(ICategorySlugBuilder categorySlugBuilder)
    {
        if (categorySlugBuilder == null)
            throw new ArgumentNullException("categorySlugBuilder");
        this.categorySlugBuilder = categorySlugBuilder;
    }

    public IDictionary<string, int> GetVirtualPathToIdMap(HttpContextBase httpContext)
    {
        var slugs = this.categorySlugBuilder.GetCategorySlugs(httpContext.Items);
        return slugs.ToDictionary(k => k.Slug, e => e.CategoryID);
    }
}

ProductCachedRouteDataProvider

And this is an implementation that provides product URLs (complete with categories, although you could omit that if you don't need it).

public class ProductCachedRouteDataProvider : ICachedRouteDataProvider<int>
{
    private readonly ICategorySlugBuilder categorySlugBuilder;

    public ProductCachedRouteDataProvider(ICategorySlugBuilder categorySlugBuilder)
    {
        if (categorySlugBuilder == null)
            throw new ArgumentNullException("categorySlugBuilder");
        this.categorySlugBuilder = categorySlugBuilder;
    }

    public IDictionary<string, int> GetVirtualPathToIdMap(HttpContextBase httpContext)
    {
        var slugs = this.categorySlugBuilder.GetCategorySlugs(httpContext.Items);
        var result = new Dictionary<string, int>();

        using (var db = new ApplicationDbContext())
        {
            foreach (var product in db.Products)
            {
                int id = product.ProductID;
                string categorySlug = slugs
                    .Where(x => x.CategoryID.Equals(product.CategoryID))
                    .Select(x => x.Slug)
                    .FirstOrDefault();
                string slug = string.IsNullOrEmpty(categorySlug) ?
                    product.UrlSegment :
                    categorySlug + "/" + product.UrlSegment;

                result.Add(slug, id);
            }
        }
        return result;
    }
}

CategorySlugBuilder

This is the service that converts the category URL segments into URL slugs. It looks up the parent categories from the category database data and appends them to the beginning of the slug.

There is a little extra responsibility added here (which I probably wouldn't do in a production project) that adds request caching because this logic is used by both the CategoryCachedRouteDataProvider and ProductCachedRouteDataProvider. I combined it here for brevity.

public interface ICategorySlugBuilder
{
    IEnumerable<CategorySlug> GetCategorySlugs(IDictionary cache);
}

public class CategorySlugBuilder : ICategorySlugBuilder
{
    public IEnumerable<CategorySlug> GetCategorySlugs(IDictionary requestCache)
    {
        string key = "__CategorySlugs";
        var categorySlugs = requestCache[key];
        if (categorySlugs == null)
        {
            categorySlugs = BuildCategorySlugs();
            requestCache[key] = categorySlugs;
        }
        return (IEnumerable<CategorySlug>)categorySlugs;
    }

    private IEnumerable<CategorySlug> BuildCategorySlugs()
    {
        var categorySegments = GetCategorySegments();
        var result = new List<CategorySlug>();

        foreach (var categorySegment in categorySegments)
        {
            var map = new CategorySlug();
            map.CategoryID = categorySegment.CategoryID;
            map.Slug = this.BuildSlug(categorySegment, categorySegments);

            result.Add(map);
        }

        return result;
    }

    private string BuildSlug(CategoryUrlSegment categorySegment, IEnumerable<CategoryUrlSegment> categorySegments)
    {
        string slug = categorySegment.UrlSegment;
        if (categorySegment.ParentCategoryID.HasValue)
        {
            var segments = new List<string>();
            CategoryUrlSegment currentSegment = categorySegment;

            do
            {
                segments.Insert(0, currentSegment.UrlSegment);

                currentSegment =
                    currentSegment.ParentCategoryID.HasValue ?
                    categorySegments.Where(x => x.CategoryID == currentSegment.ParentCategoryID.Value).FirstOrDefault() :
                    null;

            } while (currentSegment != null);

            slug = string.Join("/", segments);
        }
        return slug;
    }

    private IEnumerable<CategoryUrlSegment> GetCategorySegments()
    {
        using (var db = new ApplicationDbContext())
        {
            return db.Categories.Select(
                c => new CategoryUrlSegment
                {
                    CategoryID = c.CategoryID,
                    ParentCategoryID = c.ParentCategoryID,
                    UrlSegment = c.UrlSegment
                }).ToArray();
        }
    }
}

public class CategorySlug
{
    public int CategoryID { get; set; }
    public string Slug { get; set; }
}

public class CategoryUrlSegment
{
    public int CategoryID { get; set; }
    public int? ParentCategoryID { get; set; }
    public string UrlSegment { get; set; }
}

Route Registration

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.Add("Categories", new CachedRoute<int>(
            controller: "Category", 
            action: "Index", 
            dataProvider: new CategoryCachedRouteDataProvider(new CategorySlugBuilder())));

        routes.Add("Products", new CachedRoute<int>(
            controller: "Product",
            action: "Index",
            dataProvider: new ProductCachedRouteDataProvider(new CategorySlugBuilder())));

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

Now, if you use the following code in a controller action or view:

var product1 = Url.Action("Index", "Product", new { id = 1 });

The result of product1 will be

/category-1/category-2/category-3/product-1

And if you enter this URL into the browser, it will call the ProductController.Index action and pass it id 1. When the view returns, the breadcrumb is

Home > Категории > Категории2 > Категории3 > Prod1

You could still improve things, such as adding cache busting for the route URLs, and adding paging to the categories (although these days most sites go with infinite scroll rather than paging), but this should give you a good starting point.

Upvotes: 5

Related Questions