Help shape the future of CMS PaaS release notes! Take this quick survey and share your feedback. 

Magnus Rahl
Mar 18, 2015
  8090
(5 votes)

Routing context aware links in Commerce catalog

Catalog entries in the Commerce catalog can be linked to multiple categories. For each such link there will be a corresponding hierarchical route based on the category structure (e.g. /catalog/t-shirts/batman and /catalog/spring-sale/batman). If the site navigation/listings are built from the catalog structure the entry can be found in multiple locations. However, each entry has a home location (the ParentLink of the Content) that will always be used to generate URLs to it. And even if a URL is created using the path of one of the alternate categories the entry detail renderer has no (readily accessible) information about which URL/route was used to find it.

Tracing the incoming route

Hierarchical routes to catalog content is handled by the HierarchicalCatalogPartialRouter class, which is usually registered by calling CatalogRouteHelper.MapDefaultHierarchialRouter in an initialization module. Let's create a derived class and store a breadcrumb of the routed path to the content:

public class BreadcrumbHierarchicalCatalogPartialRouter : HierarchicalCatalogPartialRouter
{
    public static readonly string BreadcrumbKey = "RoutedBreadcrumb";

    public BreadcrumbHierarchicalCatalogPartialRouter(
        Func<ContentReference> routeStartingPoint,
        CatalogContentBase commerceRoot)
        : base(routeStartingPoint,
            commerceRoot,
            false,
            ServiceLocator.Current.GetInstance<IContentLoader>(),
            ServiceLocator.Current.GetInstance<LanguageSelectorFactory>(),
            ServiceLocator.Current.GetInstance<IRoutingSegmentLoader>(),
            ServiceLocator.Current.GetInstance<IContentVersionRepository>())
    {
    }

    protected override CatalogContentBase GetCatalogContentRecursive(CatalogContentBase catalogContent,
        SegmentPair segmentPair, SegmentContext segmentContext, ILanguageSelector languageSelector)
    {
        if (catalogContent != null)
        {
            AddBreadcrumbData(segmentContext, catalogContent.ContentLink);
        }
        return base.GetCatalogContentRecursive(catalogContent, segmentPair, segmentContext, languageSelector);
    }

    private void AddBreadcrumbData(SegmentContext segmentContext, ContentReference contentLink)
    {
        var breadcrumb = segmentContext.GetCustomRouteData<IList<ContentReference>>(BreadcrumbKey);
        if (breadcrumb == null)
        {
            breadcrumb = new List<ContentReference>();
            segmentContext.SetCustomRouteData(BreadcrumbKey, breadcrumb);
        }
        breadcrumb.Add(contentLink);
    }
}

We also need to register this instead of using CatalogRouteHelper.MapDefaultHierarchialRouter:

[ModuleDependency(typeof(EPiServer.Commerce.Initialization.InitializationModule))]
public class InitializationModule : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        MapRoutes(RouteTable.Routes);
    }

    private static void MapRoutes(RouteCollection routes)
    {
        //CatalogRouteHelper.MapDefaultHierarchialRouter(routes, false);
        var referenceConverter = ServiceLocator.Current.GetInstance<ReferenceConverter>();
        var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();
        var commerceRootContent = contentLoader.Get<CatalogContentBase>(referenceConverter.GetRootLink());
        routes.RegisterPartialRouter(new BreadcrumbHierarchicalCatalogPartialRouter(() => SiteDefinition.Current.StartPage, commerceRootContent));
    }

    public void Preload(string[] parameters)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }
}

So what this does is to store the ContentLink of every content it resolves as it is recursing the route segments, and stores them in a list on the route context. This list can then be accessed in a renderer through HttpContext.Current.Request.RequestContext. I added this method to a WebForm base class to illustrate how to access the parent (the last item is the routed content itself, so the routed parent is the second to last):

protected ContentReference GetRoutedParentLink()
{
    var breadcrumb = HttpContext.Current.Request.RequestContext
        .GetCustomRouteData<IList<ContentReference>>(BreadcrumbHierarchicalCatalogPartialRouter.BreadcrumbKey);

    if (breadcrumb != null && breadcrumb.Count > 1)
    {
        return breadcrumb.Reverse().Skip(1).First();
    }

    if (CurrentData != null)
    {
        return CurrentData.ParentLink;
    }

    return CurrentPage.ParentLink;
}

If our custom router is the active one, when the batman t-shirt from the initial example is rendered, we will be able to easily see if it was routed to using /catalog/spring-sale/batman instead of its home location, and for example promote other items from the spring-sale category in the product view.

Controlling how outgoing links are rendered

The /catalog/spring-sale/batman route is active but unless we do something special any link rendered (e.g. using UrlResolver) to the batman t-shirt will still use the /catalog/t-shirts/batman URL. To be able to control how the link is created we add another override to our router:

public static readonly string PreferredParentKey = "PreferredParentLink";

public override PartialRouteData GetPartialVirtualPath(CatalogContentBase content, string language, RouteValueDictionary routeValues, RequestContext requestContext)
{
    if (!IsValidRoutedContent(content))
    {
        return null;
    }

    if (EnableOutgoingSeoUri && requestContext.GetContextMode() == ContextMode.Default)
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var preferredParent = routeValues[PreferredParentKey] as ContentReference;
    routeValues.Remove(PreferredParentKey);
            
    if (preferredParent == null)
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();

    if (!contentLoader.GetChildren<CatalogContentBase>(preferredParent)
        .Any(c => c.ContentLink.CompareToIgnoreWorkID(content.ContentLink)))
    {
        return base.GetPartialVirtualPath(content, language, routeValues, requestContext);
    }

    var parentContent = contentLoader.Get<CatalogContentBase>(preferredParent);

    string virtualPath;
    if (!TryGetVirtualPath(requestContext.HttpContext, parentContent, language, out virtualPath))
    {
        return null;
    }

    virtualPath = virtualPath.EndsWith("/")
        ? virtualPath + content.RouteSegment
        : virtualPath + "/" + content.RouteSegment;

    var segmentUrl = SegmentHelper.GetModifiedVirtualPathInEditOrPreviewMode(content.ContentLink, virtualPath, requestContext.GetContextMode());
    return new PartialRouteData { BasePathRoot = RouteStartingPoint, PartialVirtualPath = segmentUrl };
}

The beginning of this method is just a number of checks I had to duplicate from the base class. And of course you should rework the constructors to take the IContentLoader as a dependency instead of fetching it from the ServiceLocator. In addition to this, the base implementation caches the created urls in context.Items to reuse them througout the request lifetime, which would probably be a good idea here too (varying the cache key on contentlink, language and preferred parent).

But other than that the logic is simple: If there's a preferred parent supplied, see if the routed content is actually a child of that content and in that case create a URL based on that parent instead. In our example it would get the URL to the spring-sale category, /catalog/spring-sale and append batman to get the /catalog/spring-sale/batman URL we want.

To get the preferred parent to the UrlResolver, use the VirtualPathArguments in the call like in this helper method:

protected string GetUrl(CatalogContentBase content, ContentReference preferredParent)
{
    var args = new VirtualPathArguments {RouteValues = new RouteValueDictionary()};
    if (preferredParent != null)
    {
        args.RouteValues[BreadcrumbHierarchicalCatalogPartialRouter.PreferredParentKey] = preferredParent;
    }

    return content != null ?
        VirtualPathUtilityEx.ToAbsolute(UrlResolver.Current.GetUrl(content.ContentLink, content.Language.Name, args)) :
        string.Empty;
}

(Again, you probably want to get the UrlResolver injected to remove the static call).

Using the MVC Html.ContentLink helper you should be able to pass the preferred parent in the routeValues argument.

Summary

With all these parts in place you should be able to create an experience where:

  • When the user enters the sale category and moves on to a product, you can display the product with a link/breadcrumb back to the sale and promote other sale items directly in the product view.
  • When the user enters through another of the product's parent categories you can display a link/breadcrumb back to that category and other related products/information that are more relevant in that context.

And a final tip: If you do this you should consider canonical links to your content. I guess the strategy for that might vary depending on how much your views vary by the routed parent.

Mar 18, 2015

Comments

K Khan
K Khan Mar 19, 2015 12:55 PM

Bundle of Thanks :)

Brian Weeteling
Brian Weeteling Feb 20, 2019 09:46 PM

Thanks Magnus. We're using (parts of) this in some of our projects as well. Be careful with the code for outgoing links as it caused us some performance issues: https://www.brianweet.com/2019/02/20/multi-site-catalog-performance-regression.html

Please login to comment.
Latest blogs
How to: set access right to folders

Today I stumped upon this question Solution for Handling File Upload Permissions in Episerver CMS 12, and there is a simple solution for that Using...

Quan Mai | Feb 7, 2025 | Syndicated blog

Poking around in the new Visual Builder and the SaaS CMS

Early findings from using a SaaS CMS instance and the new Visual Builder grids.

Johan Kronberg | Feb 7, 2025 | Syndicated blog

Be careful with your (order) notes

This happened a quite ago but only now I have had time to write about it. Once upon a time, I was asked to look into a customer database (in a big...

Quan Mai | Feb 5, 2025 | Syndicated blog

Imagevault download picker

This open source extension enables you to download images as ImageData with ContentReference from the ImageVault picker. It serves as an alternativ...

Luc Gosso (MVP) | Feb 4, 2025 | Syndicated blog

Optimizely SaaS vs PaaS: A Comparison from Client and Developer Perspectives

Optimizely, one of the leading digital experience platform. Offering both Software-as-a-Service (SaaS) and Platform-as-a-Service (PaaS) solutions....

Praful Jangid | Feb 3, 2025

Returning to Optimizely After Many Years

Returning to Optimizely After Many Years: A Journey Through Its New Features After several years away from Optimizely’s Content Management … More

Jose Neto | Feb 2, 2025 | Syndicated blog