Placeholder Settings Rules

Posted Wednesday, July 10, 2013 4:00 PM by Kevin

We just had a requirement come up on my current project where they wanted to restrict the number of sublayouts of a certain type on a specific page.  They are using the unified page editor, so the thought was that when the "Select a Rendering" dialog popped up, a certain sublayout would only appear if there weren't already X of them on the page.

To me, this had rules engine written all over it.  Placeholder Settings are already used to control which renderings/sublayouts are allowed in a specific placeholder, so that seemed like the natural place to add a Rules field.  I did this using a base template which I named Placeholder Base.  Note - I did this via a base template instead of modifying Sitecore's Placeholder template directly to minimize the impact of future upgrades (at most, I may have to re add my custom base template to their template someday).  Anyway, the template looks like this:

Placeholder Base Template
Figure 1 - Placeholder Base Template

I added this template to the base templates for /sitecore/templates/System/Layout/Placeholder, so now this Rules field shows up on any placeholder settings item.

If you're not familiar with the Rules field type, the source I specified above is a new folder that I created to hold my custom actions and conditions.  Sitecore will add any actions or conditions it finds there to the list of available conditions and actions when you are editing this field.  Here's a screenshot of that folder structure:

Placeholder Settings Rules Folder
Figure 2 - Placeholder Settings Rules Folder

Sitecore has a pipeline specifically for determining which renderings are allowed in a placeholder.  You can find the list of processors in web.config in the <getPlaceholderRenderings> element.  The first processor here is GetAllowedRenderings which is exactly what I want to change the behavior of.  I created my own class that inherits from Sitecore.Pipelines.GetPlaceholderRenderings.GetAllowedRenderings and overrides the GetRenderings() method:

public class GetPlaceholderRenderingsProcessor : Sitecore.Pipelines.GetPlaceholderRenderings.GetAllowedRenderings
{
    private ID DeviceId;
    private string PlaceholderKey;
    private Database ContentDatabase;
    private string LayoutDefinition;

    protected override List<Item> GetRenderings(Item placeholderItem, out bool allowedControlsSpecified)

    {
        // ... see attachment for code ...
    }

    public void Process(GetPlaceholderRenderingsArgs args)
    {
        DeviceId = args.DeviceId;
        PlaceholderKey = args.PlaceholderKey;
        ContentDatabase = args.ContentDatabase;
        LayoutDefinition = args.LayoutDefinition;

        // ... code pasted in from Reflector goes here ...
    }
}

Additionally, I later found that I needed to know some additional information in order to evaluate my custom conditions.  Information that is only available within the Process() method of the pipeline processor.  Since Sitecore's Process method is not virtual, I had to create my own, copy/paste their implementation in from Reflector, and add some code to stash the information I needed in some private fields.  You can also see that code above.

In order to pass information in and out of the rules engine, I needed to implement a custom rule context.  Here's what that class looks like:

public class PlaceholderSettingsRuleContext : RuleContext
{
    public List<Item> AllowedRenderingItems { get; set; }
    public ID DeviceId { get; set; }
    public string PlaceholderKey { get; set; }
    public Database ContentDatabase { get; set; }
    public string LayoutDefinition { get; set; }
}

This has properties for the DeviceId, PlaceholderKey, ContentDatabase, and LayoutDefinition that I got from the processor's Process method as well as one for the list of allowed renderings coming out of the rules engine.

So, now I think I can discuss the GetRenderings method on the pipeline processor.  First, I needed to get the initial list of allowed renderings from the base class:

var list = base.GetRenderings(placeholderItem, out allowedControlsSpecified);

Next, I needed to get the rules from the current placeholder settings item that's passed in to the method.  Note - Sitecore stores the rules as an XML string.  If the string is null or empty, I just returned the list from the base class.

string rulesXml = placeholderItem["Allowed Controls Rules"];
if (string.IsNullOrWhiteSpace(rulesXml)) return list;

This is where I constructed the custom rule context that will be passed into the rules engine.  Note, I passed in the initial list of allowed renderings so that the rules may add or remove items from the list:

PlaceholderSettingsRuleContext context = new PlaceholderSettingsRuleContext();
context.Item = placeholderItem;
context.AllowedRenderingItems = list;
context.DeviceId = DeviceId;
context.PlaceholderKey = PlaceholderKey;
context.ContentDatabase = ContentDatabase;
context.LayoutDefinition = LayoutDefinition;

So that just leaves parsing and executing the rules:

var parsedRules = RuleFactory.ParseRules<PlaceholderSettingsRuleContext>(placeholderItem.Database, rulesXml);
RuleList<PlaceholderSettingsRuleContext> rules = new RuleList<PlaceholderSettingsRuleContext>()
rules.Name = placeholderItem.Paths.Path;
rules.AddRange(parsedRules.Rules);
rules.Run(context);

Sitecore wants to know if any allowed controls are being returned or not.  I think this is new in 6.6…  Anyhow, if this is false - Sitecore will allow the user to choose a rendering from a tree view.

if (context.AllowedRenderingItems.Count < 1)
    allowedControlsSpecified = false;
else
    allowedControlsSpecified = true;

And lastly, I returned the final list of allowed renderings:

return context.AllowedRenderingItems;

To activate the pipeline processor, I created a .config file in the App_Config\Include directory that replaces the stock Sitecore GetAllowedRenderings processor with my custom one.  It looks like this:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <pipelines>
      <getPlaceholderRenderings>
        <processor patch:instead="*[@type='Sitecore.Pipelines.GetPlaceholderRenderings.GetAllowedRenderings, Sitecore.Kernel']"
                   type="MyNamespace.GetPlaceholderRenderingsProcessor, MyAssembly" />
      </getPlaceholderRenderings>
    </pipelines>
  </sitecore>
</configuration>

At this point, Sitecore was processing rules when a user wanted to insert a new sublayout through the unified page editor.  But, the rules had no way to evaluate if a sublayout should be allowed or not and had no way to allow or disallow sublayouts.  Enter a custom condition and custom actions.

For my condition, I decided to inherit from Sitecore's IntegerComparisonCondition.  This allowed me to control sublayout visibility based on the existing number of sublayouts of that type on the page using "is greater than" or "is less than".  Here's what the condition code looks like:

public class SublayoutCountCondition<T> : IntegerComparisonCondition<T> where T : PlaceholderSettingsRuleContext
{
    public string SublayoutId { get; set; }

    protected override bool Execute(T ruleContext)
    {
        // Parse the layout definition.
        LayoutDefinition layoutDef = LayoutDefinition.Parse(ruleContext.LayoutDefinition);
        if (layoutDef == null) return false;

        // Convert sublayout id string to ID.
        ID sublayoutId = new ID(SublayoutId);

        // Find the device definition for the current device.
        DeviceDefinition deviceDef = null;
        foreach (DeviceDefinition dd in layoutDef.Devices)
        {
            if (new ID(dd.ID) == ruleContext.DeviceId)
            {
                deviceDef = dd;
                break;
            }
        }
        if (deviceDef == null) return false;

        // Loop through the rendering definitions for the device and count the instances of SublayoutId.
        int renderingCount = 0;
        foreach (RenderingDefinition rendering in deviceDef.Renderings)
        {
            if (new ID(rendering.ItemID) == sublayoutId)
                renderingCount++;
        }
        // Evaluate the condition.
        return base.Compare(renderingCount);
    }

The SublayoutId property gets set by a variable in the condition definition in Sitecore (see Figure 3 below).  The Execute method grabs the layout definition from the context, parses it into a LayoutDefinition object, locates the definition for the device being edited, and counts up the instances of the RenderingId.  Lastly, it calls the base Compare method to do the comparison defined in the rule.  Here's what the condition definition looks like in Sitecore (using the /sitecore/templates/System/Rules/Condition template):

Count Sublayouts Condition
Figure 3: Count Sublayouts Condition Definition

If you're not familiar with custom rules engine conditions, the Type is the class up above and the Text is the text that appears when the condition is added to a rule.  Variables are defined in square brackets using four comma-separated values.  The first variable is SublayoutId from my above class, and the other two variables exist in the IntegerComparisonCondition base class.  Here's a breakdown of the four comma-separated values in the SublayoutId variable:

  • SublayoutId - The name of the property on the condition class to be set.
  • tree - The type of UI element to present to the user for choosing a value.
  • /sitecore/Layout/Sublayouts - The root of the tree used to choose a value.
  • sublayout - The text that's displayed in the rules editor before a value has been chosen.
Here's what the condition looks like when it is first added to a rule (before values have been selected for any of the variables):

Condition Initial State
Figure 4: Condition Initial State

The second half of the rule was the custom actions that allow or disallow a sublayout.  All these needed to do was add (or remove) a sublayout from the context's AllowedRenderingItems property.  Here's the code:

public class AllowSublayoutAction<T> : PlaceholderSettingsAction<T> where T : PlaceholderSettingsRuleContext
{
    public string SublayoutId { get; set; }

    public override void Apply(T ruleContext)
    {
        // Create a new list if one hasn't been created yet.
        if (ruleContext.AllowedRenderingItems == null)
            ruleContext.AllowedRenderingItems = new List<Item>();

        // If the sublayout is already in the context, do nothing.
        if (ruleContext.AllowedRenderingItems.Any(i => i.ID == new ID(SublayoutId)))
            return;

        // Add the sublayout to the context.
        ruleContext.AllowedRenderingItems.Add(ruleContext.Item.Database.GetItem(new ID(SublayoutId)));
    }
}

public class DontAllowSublayoutAction<T> : PlaceholderSettingsAction<T> where T : PlaceholderSettingsRuleContext
{
    public string SublayoutId { get; set; }

    public override void Apply(T ruleContext)
    {
        // If there are no renderings, do nothing.
        if (ruleContext.AllowedRenderingItems == null) return;

        // If the sublayout already isn't in the context, do nothing.
        Item item = ruleContext.AllowedRenderingItems.FirstOrDefault(i => i.ID == new ID(SublayoutId));
        if (item == null) return;

        // Remove the sublayout from the context.
        ruleContext.AllowedRenderingItems.Remove(item);
    }
}

Similar to the condition, the SublayoutId property in the actions gets set by a variable.  Here's what the action definitions look like in Sitecore (using the /sitecore/templates/System/Rules/Action template):

Allow Sublayout Action
Figure 5: Allow Sublayout Action Definition

Don't Allow Sublayout Action
Figure 6: Don't Allow Sublayout Action Definition

As shown in Figure 2 above, the actions and conditions were created in the folder that is referenced in the source for the Rules field in Figure 2.  This made them available when editing that Rules field in Sitecore.  Here's a screenshot of me editing the rule for a placeholder setting:

Editing a Rule
Figure 7: Editing a Placeholder Settings Rule

I've labelled 1) Where the custom condition appears.  2) Where the custom actions appear.  3) An example rule.  Note in the example, I have included three sublayouts (Accessories Layer, Feature Layer, and Gallery Layer) using the usual "Allowed Controls" field on the placeholder settings item.  The rule only disallows the Gallery Layer sublayout if there is already one on the page (the number is greater than 0).

That's about it.  I will edit this post with a link to a demo video and a ZIP file with the source code when I have them prepared.  In the meantime, if you have any questions about this - feel free to reach out to me via Twitter @williamsk000.

Comments

No Comments