Include renderings with Sitecore MVC

This post is about extending Sitecore to simplify reuse of content. It’s nothing new and has been done many times before. This is just my take on a common scenario. The solution is only tested on Sitecore 8.0 & 8.1, but will probably work on older versions as well. There are many usages for this extensions, but I’ll first try to explain the requirement I had and why this extension was needed.

As a start, we have a multi-brand, multi-lingual and multi-market set of websites in one Sitecore instance, where each market have its own cloned branch. How many doesn’t really matter, but the important thing here is that we have multiple sites or branches.

As with almost every site, we have a set of components that are the same on all or many pages, such as the header, navigation, footers etc. We don’t want to define and manage those on every page, so in Sitecore one typically define those in the template standard values or bind them statically in the layout. In both cases, it becomes pretty static.

In our scenario, we want the editors to be able change these parts of the website as well. We want the header to be adapted per site, the menu needs personalisation ability and the sites uses a “mega menu” where editors can put campaigns and offers inside the menu. Editor should be able to make these changes in one location for each site, using the Experience Editor, and changes should apply to multiple pages (parts or the whole site, but only one of the set of sites).

This means we cannot bind those components statically. Neither can we do it using template standard values, because that would apply to all markets, sites etc. And of course we cannot let the editors do these kind of changes to every page on their sites. So let’s fix this and extend Sitecore a bit.

This is pretty similar to what Alex Shyba wrote about Cascading Renderings.

Note that this solution applies to Sitecore MVC only. You can probably do the same in WebForms, but it would involve other pipelines.

The idea is to have separate items representing for example a header/menu for each site. Those items will not be navigable when published, but they do have a layout that’s used internally so that it can be easily edited in the Experience Editor. The renderings we place on these items will be reused on all the pages for the current website.

On the website pages, we add a specific Include Rendering renderer. This renderer will pick up this header/menu item. At runtime, the include rendering will be replaced with all the renderings on the mega menu item.

By default, rendering datasources are stored as Guid references to its target item. We cannot use Sitecore queries here and there is a good reason for that. Sitecore queries are slower, potentially a lot slower, than resolving just a Guid. Otherwise it would be very tempting to reference the an included item using a Sitecore query such as query:./ancestor-or-self::*[@@templatekey='website container']/Settings/MegaMenu

One option would be to add a pipeline to actually support this, and it could make perfectly sense to do so in some scenarios. One could be where you know the queries won’t be too complex or when performance won’t be an issue.

A second alternative could be to support this in the editor only and add some logic to the publisher, so that the queries are resolved at publish time and have the proper ID’s published to the web databases.

A third alternative is to hook up some custom code to resolve this in a faster way than just standard queries and perform the lookup runtime. This may seem a bit odd, but would make sense in some scenarios as well. If one can identify the renderings on the template standard values, a Sitecore query as described above would make perfect sense since developers would write it. But it’s rather complex for editors to write, so in a scenario where you want editors to add a complex set of reused components on multiple pages, but maybe not all pages, it would be nice if the datasource could be fixed without editor interaction.

But first, let’s look at how we can include renderings from another page.

Time spending extending SitecoreThere are a few places we can hook into to solve this, and I found it best to add a step in the mvc.getXmlBasedLayoutDefinition pipeline. This pipeline is executed during the build page definition process, and it solves things like applying layout deltas to standard values, final renderings etc. and as a return we get the full rendering xml including devices, rules sets etc.

So I appended a new pipeline step into the mvc.getXmlBasedLayoutDefinition pipeline. It searches for any of our specific (configurable) include rendering item. When one is found, it loads the item pointed out by its datasource and executes the same pipeline again. Thereby we also make recursion possible. Since the result of the pipeline is an xml of the whole layout, we have to pick only the renderings (the <r> elements) that applies to the current device. Secondly, remember that renderings without a datasource, sees the current item as source. Since we move renderings from one item into another, we have to set the datasource if it’s missing.

To prevent accident endless recursion loops, I build up a set of executed items and pass those as CustomData in the PipelineArgs, so that the process can be stopped if the current item is found in the set of already processed items. I also decided to add an additional rendering parameter indicating when a rendering is being included. In that way I can adjust the rendering if needed.

Worth noticing is that previous versions of Sitecore had a lot of ready-made methods for doing this, but I’ve discovered that since the introduction of Sitecore MVC, the built in classes for this now parses the XML manually as XDocuments. I guess there is a good reason for that, and I’ll just do it in the same way in this example.

Then, I want this to play nice with the Experience Editor as well. The problem is that if we include a few renderings from another item, the “Add here” buttons would be all messed up and a few other things as well. We want the included section to remain intact. Therefore I don’t perform the rendering replace when the PageMode is in IsPageEditorEditing mode. Instead, the include rendering renders itself as a placeholder.

So the include processor takes a set of view renderings (or any rendering actually) as configuration arguments. Whose renderings will be treated as include renderings in normal rendering mode, but in editing mode the view rendering will render itself. Thereby we can create a generic include view rendering that just renders a common placeholder in editing mode, and we can also create specific renderings that renders more specific code in the editor that makes more sense to authors.

Here’s also where the third option comes into play. Where I configure the include renderings in the config, I can also specify a datasource resolver class that is executed in normal rendering mode. This would be the preferred option if the datasource can’t be specified on standard values.

So here’s the code for it:

public class AddIncludeItemRenderings : GetXmlBasedLayoutDefinitionProcessor
{
    public const string IncludedItemParameterName = "included_rendering";
    public Dictionary<Guid, IncludeRendering> IncludeItemRenderingGuidSet { get; private set; }

    public AddIncludeItemRenderings()
    {
        IncludeItemRenderingGuidSet = new Dictionary<Guid, IncludeRendering>();
    }

    public override void Process(GetXmlBasedLayoutDefinitionArgs args)
    {
        Assert.ArgumentNotNull(args, "args");
        if (args.Result == null || Context.Site == null)
            return;

        try
        {
            var includeElements = args.Result
                .Elements("d").Elements("r")
                .Where(e => IncludeItemRenderingGuidSet.ContainsKey((Guid)e.Attribute("id")));

            foreach (var includeElement in includeElements)
            {
                // ReSharper disable once PossibleNullReferenceException
                var deviceId = (Guid) includeElement.Parent.Attribute("id");
                var renderingId = (Guid) includeElement.Attribute("id");
                var dataSource = includeElement.GetAttributeValueOrNull("ds");

                if (string.IsNullOrWhiteSpace(dataSource))
                {
                    var renderingItem = RenderingItem.GetItem(new ID(renderingId), args.PageContext.Database, true);
                    dataSource = renderingItem.DataSource;
                }

                if (string.IsNullOrWhiteSpace(dataSource))
                {
                    var type = IncludeItemRenderingGuidSet[renderingId].Type;
                    if (type != null)
                    {
                        var processor = (IIncludeProcessor) Activator.CreateInstance(type);
                        dataSource = processor.GetDatasource(includeElement, args.ContextItem ?? args.PageContext.Item);
                    }
                }
                if (string.IsNullOrWhiteSpace(dataSource))
                {
                    includeElement.Remove();
                    continue;
                }

                if (Context.PageMode.IsPageEditorEditing)
                {
                    // Let the rendering render itself
                    continue;
                }

                Item item = null;
                if (ID.IsID(dataSource))
                {
                    item = args.PageContext.Database.GetItem(new ID(dataSource));
                }
                else if (dataSource.StartsWith("query:"))
                {
                    item = args.PageContext.Item.Axes.SelectSingleItem(dataSource.Substring("query:".Length));
                    dataSource = item.ID.ToString();
                }

                if (item == null)
                {
                    includeElement.Remove();
                    continue;
                }

                var includeArgs = new GetXmlBasedLayoutDefinitionArgs();
                includeArgs.ContextItem = item;
                var items = (args.CustomData["includedItems"] as ISet<ID>) ?? new HashSet<ID>();
                if (items.Contains(item.ID))
                {
                    // Recursion occured. Break.
                    Log.Error("Found recursive inclusion!", this);
                    includeElement.Remove();
                    continue;
                }
                items.Add(item.ID);
                includeArgs.CustomData["includedItems"] = items;

                var includeLayoutDefinition = PipelineService.Get()
                    .RunPipeline("mvc.getXmlBasedLayoutDefinition", includeArgs, a => a.Result);

                var replacementElements = includeLayoutDefinition
                    .Elements("d").SingleOrDefault(e => (Guid) e.Attribute("id") == deviceId);
                if (replacementElements == null || replacementElements.IsEmpty)
                {
                    includeElement.Remove();
                    continue;
                }

                foreach (var replacementElement in replacementElements.Elements("r"))
                {
                    if (string.IsNullOrWhiteSpace((string)replacementElement.Attribute("ds")))
                    {
                        replacementElement.SetAttributeValue("ds", dataSource);
                    }

                    var par = (string)replacementElement.Attribute("par");
                    par += (!string.IsNullOrWhiteSpace(par) ? "&" : "") + IncludedItemParameterName + "=true";
                    replacementElement.SetAttributeValue("par", par);
                }
                includeElement.ReplaceWith(replacementElements.Elements("r"));
            }
        }
        catch (Exception ex)
        {
            Log.Error("Error replacing include renderings", ex, this);
        }
    }

    public void AddIncludeItemRendering(XmlNode arg)
    {
        var renderingElement = arg as XmlElement;
        if (renderingElement == null)
            return;

        Guid guid;
        if (!Guid.TryParse(renderingElement.GetAttribute("id"), out guid))
            return;

        if (IncludeItemRenderingGuidSet.ContainsKey(guid))
            return;

        Type type = null;
        var typeName = renderingElement.GetAttribute("processor");
        if (!string.IsNullOrWhiteSpace(typeName))
        {
            type = Type.GetType(typeName);
            if (type == null || !typeof(IIncludeProcessor).IsAssignableFrom(type))
                type = null;
        }
        var rendering = new IncludeRendering(guid, type);

        IncludeItemRenderingGuidSet.Add(rendering.Id, rendering);
    }

    public class IncludeRendering
    {
        public Guid Id { get; private set; }
        public Type Type { get; private set; }

        public IncludeRendering(Guid id, Type type)
        {
            Id = id;
            Type = type;
        }
    }
}

public interface IIncludeProcessor
{
    /// <summary>
    /// Resolves a datasource for a include rendering
    /// </summary>
    /// <param name="rendering"></param>
    /// <param name="contextItem"></param>
    /// <returns>A string, typically an ID, representing the target ID</returns>
    string GetDatasource(XElement rendering, Item contextItem);
}
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <mvc.getXmlBasedLayoutDefinition>
        <processor patch:after="*[@type='Sitecore.Mvc.ExperienceEditor.Pipelines.Response.GetXmlBasedLayoutDefinition.SetLayoutContext, Sitecore.Mvc.ExperienceEditor']"
          type="Your.Namespace.IncludeRendering.AddIncludeItemRenderings, Your.Assembly">
          <includeRenderings hint="raw:AddIncludeItemRendering">
            <includeRendering id="{5948095C-EDB3-4378-8906-92AE0ACFED7F}" processor="Your.Namespace.IncludeRendering.FooterIncludeProcessor, Your.Assembly" />
            <includeRendering id="{C71F6D80-F217-44CF-8807-CD868224E83D}" />
          </includeRenderings>
        </processor>
      </mvc.getXmlBasedLayoutDefinition>
    </pipelines>
  </sitecore>
</configuration>

Leave a Reply