[su_note note_color=”#fafafa”]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.[/su_note]
Components, Components, Components
Composition is one solution to the problem of application complexity (© Addy Osmani).
Take a look at this product details page 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 theViewContext
with the controller’sViewData
- #2. Sitecore’s
RenderingView
(theIView
that we’re returning from the controller) stores theViewContext
on theContextService.Get()
stack for themvc.renderRendering
pipeline to get to it when needed. - #3.
ViewRenderer
executed by themvc.renderRendering
uses theViewData
from theViewContext
on the stack to create theHtmlHelper
that renders partials (our components’ razors) - #4.
HtmlHelper
creates newViewContext
to render a partial but it too passes in theViewData
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.
[su_note note_color=”#fafafa”]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.[/su_note]
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 usesTemplate.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!
[su_divider][/su_divider]
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.