Thursday 29 October 2015

Dynamic placeholders and tabs in Sitecore

The problem


One issue with using placeholders embedded inside Sitecore renderings is that a placeholder can only appear once on the page. If a placeholder with the same name is placed multiple times then Sitecore doesn't know which placeholder is being referenced when it comes to adding the placeholder's controls. If you are using renderings as reusable layouts then this can be restrictive. As an example, let's say your pages have places where you want to show three columns of renderings, and what renderings go inside each component will be decided on a per-page basis. You might create a rendering that looks like this
<div class="columncontainer">
    <div class="col-33">
        @Html.Sitecore().Placeholder("left column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("middle column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("right column")
    </div>
</div>

and you can then drop that rendering on any page where you want to show three things in columns. Great, but what if you want to show two of these on the same page? Maybe on top of each other giving two rows of three columns. There is nothing stopping you adding two of the renderings to the same page, but when you do you end up with this
<div class="columncontainer">
    <div class="col-33">
        @Html.Sitecore().Placeholder("left column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("middle column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("right column")
    </div>
</div>
<div class="columncontainer">
    <div class="col-33">
        @Html.Sitecore().Placeholder("left column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("middle column")
    </div>
    <div class="col-33">
        @Html.Sitecore().Placeholder("right column")
    </div>
</div>

Now when you tell Sitecore you want to put "Featured product" in the "middle column"...how does Sitecore know which "middle column" you are reffering to? There are two on the page. This limitation can also affect you if you want to do a flexible tab component. Let's say you want a rendering that can show tabbed content and you want the number of tabs to be dynamic. This does have a workaround, when you are creating your tabs you can give each placeholder a unique id based on its index.
<div class="tabs">
    <div class="tab">
        @Html.Sitecore().Placeholder("tab_1")
    </div>
    <div class="tab">
        @Html.Sitecore().Placeholder("tab_2")
    </div>
    <div class="tab">
        @Html.Sitecore().Placeholder("tab_3")
    </div>
</div>

That will work ok, but how do you control what renderings are valid to go inside each placeholder? What renderings can be placed where is configured in the Placeholder Settings section;

/sitecore/layout/Placeholder Settings

Here you create a "Placeholder" item for each placeholder and that item contains the placeholder key and a list of renderings that are valid for that placeholder. If you have placeholders like "tab_1", "tab_2 and "tab_3", then you will need to create 3 Placeholder items, one for each tab, and for each one defining what controls can go in that tab. Not only is this a lot of messy duplication, what happens when someone wants 4 tabs?

The solution

We are going to solve this problem by implementing a solution that allows us to generate placeholder names on the fly, yet still define what renderings are valid for placing inside those placeholders despite not knowing the placeholder names at design-time.

Create the placeholder

In this example we're going to build a tabbed control that will let us add as many tabs as we want. I already have a "Featured Product" rendering and a "Basket Summary" rendering that I will be using, so the first thing I'm going to do is create a Placeholder Setting called "mytabs" that will allow me to add my Featured Product rendering and my Basket Summary rendering to my tabs.


Create the placeholder selector

In order for us to know which Placeholder Settings item the dynamic placeholders are going to use I am going to create an item that allows me to select the relevant placeholder. This isn't a mandatory part of the solution but it's a nice-to-have and I'll expand on why that is after we've viewed the complete solution.

First create the template called Placeholder Selector. Note I'm setting the Source of the droptree to the Placeholder Settings folder.


Now create a folder somewhere in your content tree that you can store these items, and create one for our dynamic tabs and select "mytabs" from the droptree.


The tab control

My tab control uses jQuery UI and I've created a rendering for it that lets me drop it onto a page. To get us started the tab control is completely static, so this is what the View looks like.
<div id="tabs">
    <ul>
        <li><a href="#tabs-1">Nunc tincidunt</a></li>
        <li><a href="#tabs-2">Proin dolor</a></li>
        <li><a href="#tabs-3">Aenean lacinia</a></li>
    </ul>
    <div id="tabs-1">
        <p>Proin elit arcu, rutrum commodo, vehicula tempus, commodo a, risus. Curabitur nec arcu. Donec sollicitudin mi sit amet mauris. Nam elementum quam ullamcorper ante. Etiam aliquet massa et lorem. Mauris dapibus lacus auctor risus. Aenean tempor ullamcorper leo. Vivamus sed magna quis ligula eleifend adipiscing. Duis orci. Aliquam sodales tortor vitae ipsum. Aliquam nulla. Duis aliquam molestie erat. Ut et mauris vel pede varius sollicitudin. Sed ut dolor nec orci tincidunt interdum. Phasellus ipsum. Nunc tristique tempus lectus.</p>
    </div>
    <div id="tabs-2">
        <p>Morbi tincidunt, dui sit amet facilisis feugiat, odio metus gravida ante, ut pharetra massa metus id nunc. Duis scelerisque molestie turpis. Sed fringilla, massa eget luctus malesuada, metus eros molestie lectus, ut tempus eros massa ut dolor. Aenean aliquet fringilla sem. Suspendisse sed ligula in ligula suscipit aliquam. Praesent in eros vestibulum mi adipiscing adipiscing. Morbi facilisis. Curabitur ornare consequat nunc. Aenean vel metus. Ut posuere viverra nulla. Aliquam erat volutpat. Pellentesque convallis. Maecenas feugiat, tellus pellentesque pretium posuere, felis lorem euismod felis, eu ornare leo nisi vel felis. Mauris consectetur tortor et purus.</p>
    </div>
    <div id="tabs-3">
        <p>Mauris eleifend est et turpis. Duis id erat. Suspendisse potenti. Aliquam vulputate, pede vel vehicula accumsan, mi neque rutrum erat, eu congue orci lorem eget lorem. Vestibulum non ante. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Fusce sodales. Quisque eu urna vel enim commodo pellentesque. Praesent eu risus hendrerit ligula tempus pretium. Curabitur lorem enim, pretium nec, feugiat nec, luctus a, lacus.</p>
    </div>
</div>
<script>
    $(function () {
        $("#tabs").tabs();
    });
</script>


In order for this to work I am also including these in the page head
<head>
    <link rel="stylesheet" href="//code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
    <script src="//code.jquery.com/jquery-1.10.2.js"></script>
    <script src="//code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
</head>


And this is what it looks like on the page

Create the tab items

As tabs are going to be configured via Sitecore we'll need templates that will allow us to create and group tab items. Create the Tab Item template, it's quite simple for now, it only contains the text we want to show on the tab itself.



Now create the tab template. This is going to be the item your tab rendering uses to work out what tabs it needs to show, and also which renderings are going to be valid for the placeholders inside the tabs. The tabs to show will be determined from the tab item's children, so that information doesn't need to go on the tab template itself, but the tab template does contain a field that allows us to select a Placeholder selector item.



Create a folder for your tab items and create a Homepage "Tab" item and two "Tab Item"s as children that represent the tabs that the control will show. The Homepage tab item references the Dynamic tabs item we created earlier.



We create a child "Tab Item" for each of the tabs we want to show and enter the Tab title.



In order to be able to select which tabs are to be shown I have extended the template my page is built on. This is just for the purposes of the article, in reality you would do something more flexible such as configure your tabs via the tab rendering's data source.


Now I amend my page and select that I want to use the Homepage tab.



Amend the Tab rendering's view

Up until now all we've being doing is creating Sitecore items and getting our data configured. Unfortunately this is often the case when developing Sitecore components; before you can write your code, the underlying data has to be there. So now that it is, let's start writing some code.

To start with I'm simply going to get the tabs working, they are not going to have dynamic placeholders just yet.
@model Sitecore.Mvc.Presentation.RenderingModel
@using Sitecore.Data.Items
@{
    ReferenceField selectedTabs = Model.Item.Fields["Selected tabs"];
    if (selectedTabs == null || selectedTabs.TargetItem == null)
    {
        if (Sitecore.Context.PageMode.IsPageEditor)
        {
            <p>No tabs selected</p>
        }
        return;
    }
    Item[] tabItems = selectedTabs.TargetItem.Children.ToArray();
}
<div id="tabs">
    <ul>
        @for(int i = 1; i <= tabItems.Length; i++)
        {
            <li><a href="#tabs-@i">@tabItems[i-1]["Tab title"]</a></li>
        }
    </ul>
    @for (int i = 1; i <= tabItems.Length; i++)
    {
        <div id="#tabs-@i">
            <p>Content for @tabItems[i-1]["Tab title"]</p>
        </div>
    }
</div>
<script>
    $(function () {
        $("#tabs").tabs();
    });
</script>


And this is how it looks when placed on the Home page



Creating dynamic placeholders

To create the placeholders we're going to use an extension to the SitecoreHelper that is going to take a reference to our Placeholder selector item, and our tab item, and create a unique placeholder name based on those items.
namespace MyNamespace.Extensions
{
    public static class SitecoreHelperExtensions
    {
        public static HtmlString DynamicPlaceholder(this SitecoreHelper helper, Item placeholder, string key)
        {
            if (placeholder == null || string.IsNullOrWhiteSpace(key))
            {
                return null;
            }
            ReferenceField placeholderField = placeholder.Fields["Placeholder Item"];
            // get a reference to the target Placeholder
            if (placeholderField == null || placeholderField.TargetItem == null)
            {
                return null;
            }
            // placeholderField.TargetItem now references an item in the "/sitecore/layout/Placeholder Settings/"
            // folder.
            // the format of our placeholder name is going to be
            // placeholderKey__{unique key for the item}
            string placeholderName = string.Format("{0}__{1}",
                placeholderField.TargetItem["Placeholder Key"],
                key);
            return helper.Placeholder(placeholderName);
        }
    }
}


Now update the view
@model Sitecore.Mvc.Presentation.RenderingModel
@using Sitecore.Data.Items
@using MyNamespace.Extensions

@{
    // Model.Item is our page
    ReferenceField selectedTabs = Model.Item.Fields["Selected tabs"];

    if (selectedTabs == null || selectedTabs.TargetItem == null)
    {
        if (Sitecore.Context.PageMode.IsPageEditor)
        {
            <p>No tabs selected</p>
        }

        return;
    }

    // selectedTabs.TargetItem is "/sitecore/content/Tabs/Homepage tab"

    ReferenceField placeholderSelector = selectedTabs.TargetItem.Fields["Tab placeholder"];

    if (placeholderSelector == null || placeholderSelector.TargetItem == null)
    {
        if (Sitecore.Context.PageMode.IsPageEditor)
        {
            <p>No placeholder selector</p>
        }

        return;
    }

    // placeholderSelector.TargetItem is "/sitecore/content/Placeholder selectors/Dynamic tabs"

    Item[] tabItems = selectedTabs.TargetItem.Children.ToArray();
}

<div id="tabs">
    <ul>
        @for(int i = 1; i <= tabItems.Length; i++)
        {
           <li><a href="#tabs-@i">@tabItems[i-1]["Tab title"]</a></li>
        }
    </ul>
    @for (int i = 1; i <= tabItems.Length; i++)
    {
        <div id="#tabs-@i">
            @Html.Sitecore().DynamicPlaceholder(placeholderSelector.TargetItem, tabItems[i - 1].ID.ToGuid().ToString("D"))
        </div>
    }
</div>

<script>
    $(function () {
        $("#tabs").tabs();
    });
</script>

For the unique key of the placeholder we're using the id of the tab item. That makes sense for our situation, but you can use anything you want really. To make the placeholder names more user-friendly you could use the name of the tab item instead. So let's view the page in edit mode now



You'll see there is no cross-hatching for the placeholders inside the tabs so we can't add renderings to them.  This is because our placeholders have names like "mytabs__<guid>" and there is no matching Placeholder in the Placeholder Settings that lets Sitecore know which renderings are valid for that placeholder.   So let's fix that next.

Amend the getPlaceholderRenderings pipeline

Sitecore uses a pipeline to work out what renderings can be added to a placeholder. If a placeholder has valid renderings it will have a cross-hatching when empty, and an "Add to here" option. The pipeline is configured in web.config and looks like this
<getPlaceholderRenderings>
    <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetAllowedRenderings, Sitecore.Kernel" />
    <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetPredefinedRenderings, Sitecore.Kernel" />
    <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.RemoveNonEditableRenderings, Sitecore.Kernel" />
    <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetPlaceholderRenderingsDialogUrl, Sitecore.Kernel" />
</getPlaceholderRenderings>

In order to tell Sitecore to get the valid renderings from the placeholder item our placeholder selector is pointing to we're going to create our own pipeline step
namespace MyNamespace.getPlaceholderRenderings
{
    public class GetDynamicKeyAllowedRenderings : GetAllowedRenderings
    {
        public new void Process(GetPlaceholderRenderingsArgs args)
        {
            Assert.IsNotNull(args, "args");

            string placeholderKey = args.PlaceholderKey;

            // placeholderKey will contain the name of our dynamic key in the fomat of
            // placeholderKey__{id of tab item}

            // the "placeholderKey" bit is what we want as that will let us know what
            // renderings are valid.  The id is just to make the key unique.

            if (placeholderKey.Contains("__"))
            {
                string[] vals = placeholderKey.Split(new string[] { "__" }, System.StringSplitOptions.RemoveEmptyEntries);

                // get the placeholderKey, we're not interested in the rest
                placeholderKey = vals.FirstOrDefault();
            }
            else
            {
                // if we don't have the __ marker in the keyname then return and do nothing as
                // this is not a dynamic placeholder

                return;
            }

            // this code below will load the valid renderings for the placeholderKey and add them
            // to the list of valid renderings.  This is what Sitecore uses to drive the Page Editor

            Item placeholderItem = null;

            if (ID.IsNullOrEmpty(args.DeviceId))
            {
                placeholderItem = Client.Page.GetPlaceholderItem(placeholderKey, args.ContentDatabase, args.LayoutDefinition);
            }
            else
            {
                using (new DeviceSwitcher(args.DeviceId, args.ContentDatabase))
                {
                    placeholderItem = Client.Page.GetPlaceholderItem(placeholderKey, args.ContentDatabase, args.LayoutDefinition);
                }
            }

            List<Item> collection = null;

            if (placeholderItem != null)
            {
                bool flag;

                args.HasPlaceholderSettings = true;
                collection = this.GetRenderings(placeholderItem, out flag);

                if (flag)
                {
                    args.CustomData["allowedControlsSpecified"] = true;
                    args.Options.ShowTree = false;
                }
            }

            if (collection != null)
            {
                if (args.PlaceholderRenderings == null)
                {
                    args.PlaceholderRenderings = new List<Item>();
                }

                args.PlaceholderRenderings.AddRange(collection);
            }
        }
    }
}


We want our step to run after the GetAllowedRenderings step so update the pipeline like so
<getPlaceholderRenderings>
   <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetAllowedRenderings, Sitecore.Kernel" />
   <processor type="MyNamespace.getPlaceholderRenderings.GetDynamicKeyAllowedRenderings, MyProject" />
   <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetPredefinedRenderings, Sitecore.Kernel" />
   <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.RemoveNonEditableRenderings, Sitecore.Kernel" />
   <processor type="Sitecore.Pipelines.GetPlaceholderRenderings.GetPlaceholderRenderingsDialogUrl, Sitecore.Kernel" />
</getPlaceholderRenderings>


I'm amending the web.config directly for simplicity. It is recommended you update the configuration via patches in the App_Config/Include folder. Now if we look at the page we see our cross-hatchings showing there are valid renderings for the placeholder, so let's set them up.

Click the cross-hatching and you'll get the usual "Add to here" option, not the random GUID attached to the placeholder name.


Next select a valid rendering.  The valid renderings are the ones we defined in Placeholder Settings/mytabs


and it appears in the tab.  Select the "Your basket" tab and "Add to here" on its cross-hatching.


Now both our tabs have their renderings



If you want to create a new tab simply create a new "Tab Item" under the "Homepage tab". If you want to re-order the tabs then change their order in Sitecore and that is the order they will appear on the page.

You might be wondering why we need the concept of the "Placeholder Selector" item, when we could simply have our controls point to the placeholder items directly.  The main reason for this "man in the middle" step is that content editors might not have the relevant access to the placeholders node in order to select the relevant placeholder.  It also allows us to define a sub-set of placeholders that support the dynamic placeholder concept so that content editors have a focussed list to choose from rather than having to choose from all placeholders.

Summary

This technique is useful for any situation where you want multiple unique placeholders but only want to define what the valid renderings are once.  In our example we want 1 to 'n' tabs and each tab allows us to drop the same renderings into it.  Other situations you might want to use this are for homepages where you have a layout of individual widgets and you want to be able to choose which widgets are shown in each location.  You can code DynamicPlaceholders right onto the layout and have them all reference the same Placeholder Settings item and they will automatically have the same valid renderings.  Note the valid renderings don't just dictate what renderings can be added to what placeholder, but also where existing renderings can be moved to.

1 comment: