Dynamic Product Detail Pages. Part 2 – Componentization

In part 1 of this series we built a functional prototype of a dynamic product detail page. It’s an MVC route with a controller behind it that stages page context and kicks off rendering pipeline(s) for the product detail page template that is Page Editor editable. We did it this way and did not hardcode our dynamic page into a custom layout document to make it flexible and customizable for developers and editors alike.

Components, Components, Components

Composition is one solution to the problem of application complexityAddy Osmani).

Take a look at this product details page sketch:

pdp-sketch

I am sure there are many ways to break it down to components (i.e. renderings). Here’s one:

  • Product Breadcrumb
  • Product Image Gallery
  • Product Overview
  • Product Features
  • Product Reviews
  • Product Identity
  • Product Price
  • Add To Cart

I assumed we have our basic structural components (containers) and things like Tab Set and Tab Panels at our disposal. BrainJocks SCORE has it. I also assumed that everything presented on this page except global navigation elements (header and footer) is product data driven. We can definitely pull in content snippets dynamically based on a simple tagging system (remember, our products are not represented as pages) but I want to keep it simple for now. And let’s assume we are going to use View Renderings to represent each component.

The next question is – how do we share product data with all our components?

In our controller from part 1:

protected virtual ActionResult RenderDynamicPage<T>(Item page, T product)
{
    // ...

    // make product data accessible to all components that need it (more in Part 2)
    this.ShareProductData(product);
 
    // ...
}

Product Data as Objects

If you haven’t data provided your product data as Sitecore items you need to make your product objects available to all your renderings. The easiest way would be to use a ViewBag or ViewData:

protected virtual void ShareProductData(Product product)
{
    ViewBag.Product = product;
}

You can then access it in your views using @ViewBag.Product. Just like that. Thanks to MVC and Sitecore working in tandem the ViewData from the controller will reach every component on the page:

  • #1. MVC’s ViewResult creates the ViewContext with the controller’s ViewData
  • #2. Sitecore’s RenderingView (the IView that we’re returning from the controller) stores the ViewContext on the ContextService.Get() stack for the mvc.renderRendering pipeline to get to it when needed.
  • #3. ViewRenderer executed by the mvc.renderRendering uses the ViewData from the ViewContext on the stack to create the HtmlHelper that renders partials (our components’ razors)
  • #4. HtmlHelper creates new ViewContext to render a partial but it too passes in the ViewData that it carries

If you’re like me and don’t feel like talking to the @ViewBag in your razor (ideally the only C# you allow in your razor is @Model and @HtmlHelper and IsPageEditorEditing) you can expose Product as a model’s property:

public class ProductModel : RenderingModel
{
    private Product _product;

    public Product Product
    {
        get { return _product ?? (_product = GetProduct()); }
    }

    protected Product GetProduct()
    {
        // this is where it came from to the view 
        return ContextService.Get().GetCurrent<ViewContext>().ViewBag.Product;
    }
}

Product Data as Items

If you did build a data provider and your products are items in Sitecore you can do something more Sitecore-friendly.

You can data provide page items and there you have your product detail pages. If your data was not page-friendly and you decided not to make pages out of your product items you can use the technique I am about to present. And if you wonder why would someone with a data provider not build page items just remember that there’s rarely a single right answer to a system architecture problem.

With product data as items my ideal solution would deliver it to my views as data sources. You would then just use @Model.Item to get to it in your razor or this.Item to get to it in your model.

Sitecore MVC has a concept of datasource nesting. There’s a lot to be said about context items in Sitecore MVC and I recommend an earlier blog post of mine if you are new to it.

First, we will place all product data driven components into a special container rendering. The container is a very simple view rendering:

@model RenderingModel
<div class="mysite-productcontext">
    @Html.Sitecore().Placeholder("productcontext")
</div>

If you can’t place all your product components into a single container make sure you use Dynamic Placeholders and just drop multiple containers on your template page.

Then we programmatically set the datasource for this container rendering in our controller:

protected virtual void ShareProductData(Item product)
{
    ID containerId = MySite.RenderingIds.ProductContextContainer;
 
    // ToDo: gracefully handle the case when container rendering is not there
    // ToDo: if you're using multiple containers make sure you expect a list back
    Rendering container = PageContext.Current
        .PageDefinition
        .Renderings
        .Single(r => new ID(r.RenderingItemPath) == containerId);

    container.DataSource = product.ID.ToString();
}

Then we make sure that Mvc.AllowDataSourceNesting is set to true (this is the default):

<setting name="Mvc.AllowDataSourceNesting" value="true" />

That’s almost everything. All components placed into the product context container that don’t have their own datasource will see the product item on Rendering.Item (read Sitecore MVC Item Maze post to learn more). You don’t need to tell product components apart from other components to set the datasource on them directly (and there’s a nice side effect to it, I will explain below). Just put them into a product context container.

You need one more thing to safely use content containers (e.g. a Tab Set with Tab Panels) within the product context container and still deliver the product item as a datasource to, say, a Product Features component. You see the problem, right? Product context container will broadcast its datasource down thanks to the nesting feature but a Tab Panel, for example, has its own datasource item that carries the panel’s name in its fields. At that point this (the Tab Panel’s) datasource will be broadcasted down to the renderings nested inside the panel – in this case the Product Features component. We need to preserve product context as there’s no value in cascading the Tab Panel’s datasource anyway.

The EnterRenderingContext processor in mvc.renderRendering sets the RenderingContext.ContextItem and we need to write what’s called the around advice in AOP, a patch:before and patch:after in Sitecore terms. It feels like the least intrusive way that doesn’t mess with the current rendering’s datasource and only does what is needed:

<mvc.renderRendering>
  <processor 
    patch:before="processor[contains(@type, 'EnterRenderingContext')]"
    type="MySite.Custom.Pipelines.Mvc.PreserveProductContext, MySite.Custom"/>

  <processor 
    patch:after="processor[contains(@type, 'EnterRenderingContext')]"
    type="MySite.Custom.Pipelines.Mvc.PropagateProductContext, MySite.Custom"/>
</mvc.renderRendering>

Preserve:

public class PreserveProductContext : RenderRenderingProcessor
{
    public static readonly string ContextItemKey = @"MySite:ProductContext";

    public override void Process(RenderRenderingArgs args)
    {
        RenderingContext context = RenderingContext.CurrentOrNull;

        if (context == null || context.ContextItem == null)
        {
            return;
        }

        if (context.ContextItem.IsDerived(MySite.TemplateIds.DynamicPageContext))
        {
            // preserve current product context
            args.CustomData[ContextItemKey] = context.ContextItem;
        }
    }
}

Propagate:

public class PropagateProductContext : RenderRenderingProcessor
{
    public override void Process(RenderRenderingArgs args)
    {
        RenderingContext context = RenderingContext.CurrentOrNull;
        if (context == null)
        {
            return;
        }

        // preserved context
        var product = args.CustomData[PreserveProductContext.ContextItemKey] as Item;

        if (product != null && NeedToSwitchContext(context))
        {
            // propagate previously preserved product context
            context.ContextItem = product;
        }
    }

    protected virtual bool NeedToSwitchContext(RenderingContext context)
    {
        return context.ContextItem == null ||
               !context.ContextItem.IsDerived(MySite.TemplateIds.DynamicPageContext);
    }
}

* Item.IsDerived() is a simple extension method that checks the item’s template and uses Template.DescendsFrom() to check base templates.

One nice side effect is that your product components can have their own data sources if they need to. You will no longer see the product item on the Rendering.Item – it will now be the rendering’s own datasource – but you can always get to it via RenderingContext.Current.ContextItem.

Notes

We haven’t looked at how our controller gets to the product data. Two recommendations:

  • Your controller should be nothing more than a HTTP-aware router. Move all business logic into a service layer
  • Use dependency injection to set up your controller with references to the services. It decouples the two layers and makes them individually testable. Some examples here and here.

Coming Up

Our dynamic product detail page is now a real thing. We now need to:

  • Ensure our dynamic product detail pages can host WFFM forms. I will dissect WFFM MVC and tune it to work on dynamic pages (coming up in Part 3)
  • Think about SEO, performance / caching, and marketing automation / analytics (coming up in Part 4)

Check back soon!

p.s. Speaking of data providers. I ought to write about and maybe even open source a very neat read-only data provider I built last year. It uses a simple naming convention and a pluggable architecture of field value providers to move data between EF / NHibernate delivered POCOs and Sitecore items.

Pavel Veller

Add a Comment

Your email address will not be published. Required fields are marked *

Or request call back