November Happy Hour will be moved to Thursday December 5th.

Magnus Rahl
Mar 18, 2015
  7873
(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
Optimizely SaaS CMS + Coveo Search Page

Short on time but need a listing feature with filters, pagination, and sorting? Create a fully functional Coveo-powered search page driven by data...

Damian Smutek | Nov 21, 2024 | Syndicated blog

Optimizely SaaS CMS DAM Picker (Interim)

Simplify your Optimizely SaaS CMS workflow with the Interim DAM Picker Chrome extension. Seamlessly integrate your DAM system, streamlining asset...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Optimizely CMS Roadmap

Explore Optimizely CMS's latest roadmap, packed with developer-focused updates. From SaaS speed to Visual Builder enhancements, developer tooling...

Andy Blyth | Nov 21, 2024 | Syndicated blog

Set Default Culture in Optimizely CMS 12

Take control over culture-specific operations like date and time formatting.

Tomas Hensrud Gulla | Nov 15, 2024 | Syndicated blog

I'm running Optimizely CMS on .NET 9!

It works 🎉

Tomas Hensrud Gulla | Nov 12, 2024 | Syndicated blog

Recraft's image generation with AI-Assistant for Optimizely

Recraft V3 model is outperforming all other models in the image generation space and we are happy to share: Recraft's new model is now available fo...

Luc Gosso (MVP) | Nov 8, 2024 | Syndicated blog