Thursday 30 March 2017

Restricting the number of sub items in Sitecore

In this blog I will be showing a technique that lets us use the Sitecore Rules Engine to restrict how many sub-items an item can have. This restriction is fully configurable and can be based on template types, path locations, item names or any other condition you would like to define.


It's not that uncommon that you will get a request from the management at your company that they want to restrict the number of sub-items an item can have. The rationale behind this is usually when sub-items are converted into visual items on the page, for example a "Menu" component might get the menus to show from the sub-items on its datasource so it may be configured something like this



"Main Menu" has the template "Navigation Menu" and "File", "Edit" etc have the template "Navigation Section". So the menu datasource item will be the "Main Menu" item and that component will then show a menu for File, Edit and so on. Any issues so far? Not really, but someone will probably tell you that they want to restrict the number of menu items to five or some other arbitrary number. The next few paragraphs are simply a rant about company politics, if you just want to just get to the code then skip ahead to "The Solution" :)

When you ask them why they want to restrict the number of sub-items they will say that the page "looks bad" if there are 10 menu items. My response to this is predictably; "So don't add 10 menu items". You would think that would be the end of the conversation but alas it never is. The way I see it is that you can preview your changes to see if they "look bad", and if they do you simply don't publish, instead you revise what you're doing. However it is very common to be told that their content editors don't do this and if you don't restrict the number of menu items then they will add as many as they think they need. It's not for me to tell companies that they should fire their content editors and replace them with ones that have some common sense, so instead I explain to them about "Workflow", and how it allows their organisation to ensure that all changes have to be vetted by someone before they can go live. The response to this varies from "we don't want the hassle" or "We already have Workflow but these changes still go live".

So let's sum up, you have an organisation that doesn't employ a single person that has the common sense to preview a page and say "that looks bad, I'm not going to publish". This sounds like a joke, it sounds like a problem that doesn't actually exist in the real world but you would be surprised how often I am confronted with this very situation. Reading between the lines I think the real issue is that these companies simply don't trust the people they employ so the real solution is to train your staff, trust your staff, or employ better staff.

The Solution

Going back to our specific example of the menu, we could tackle the issue of excessive sub-items in the code for the rendering. We could simply stop processing sub items once we hit our limit of 5, and we could make that limit configurable somewhere, but this has some issues. First of all if a content editor adds 10 items and only sees 5 on the page they are not going to know why and it will likely be considered a bug. Secondly it needs the code of the component to be amended so the developer will have to know if the component should be restricted in the number of sub-items it should process. If the company changes their mind about a component and wants to retro-add a limit then that is development work, plus a possible push to production.

So what we're going to do instead is use the rules engine to allow us to configure the number of sub-items of a certain template that an item can have. At the end of this exercise we will be configuring a rule that looks like this



This rule is going to run when an item is created. Navigation Section is the template of the item we are trying to restrict and the rule is saying that when a Navigation Section item is created, if there are greater than 5 of those items at the same level of the tree (ie more than 5 siblings) then delete the item that has just been created.

That is us covered for when items are created, but we also need to consider the scenario when items are moved. I might create a Navigation Section elsewhere and move it to a node that already has 5 of these items as children. That rule is going to look like this



Here we are saying that if a Navigation Section item is moved and there are now more than 5 of them, cancel the move so the item returns to its original location.

At this point I'll also mention custom Insert Options. Using these kinds of rules you could specify that if there are 5 items of a certain type under the current item then to remove the target template from the list of valid insert options. This is something that you could certainly do in addition to the above, however you have to remember that admins can use "Insert from template" to bypass this, and again we might confuse content editors when they expect to see an option to insert but they can't. With the rules we are implementing you will see the user actually gets a message box telling them why they cannot create the item.

In order to implement these kinds of rules we need to do two things. First we need to create the custom rules and actions, and second we need to have our rules executed when items are created or moved.

Creating the custom rules

We're going to create a few custom conditions that will be of help when developing this kind of solution, even if they don't all appear in our final rules. We are going to create a condition that counts how many child items an item has, a condition that counts how many child items of a certain template there are, and finally a condition that counts have many siblings of a certain template they are. These conditions are all going to use common base class to do manage the logic.

using Sitecore.Rules;
using Sitecore.Rules.Conditions;
using System;

namespace MyNamespace
{
    public abstract class BaseOperatorCondition<T> : OperatorCondition<T> where T : RuleContext
    {
        protected override bool Execute(T ruleContext)
        {
            // this will be overriden by the derived class
            return false;
        }

        protected bool IsMatch(int value, int target)
        {
            switch (base.GetOperator())
            {
                case ConditionOperator.Equal:
                    return value == target;
                case ConditionOperator.GreaterThan:
                    return value > target;
                case ConditionOperator.GreaterThanOrEqual:
                    return value >= target;
                case ConditionOperator.LessThan:
                    return value < target;
                case ConditionOperator.LessThanOrEqual:
                    return value <= target;
                case ConditionOperator.NotEqual:
                    return value != target;
                default:
                    return false;
            }
        }
    }
}

The base class inherits from OperatorCondition which is a Sitecore class used to create rule conditions that rely on comparison operators such as "equal to", "greater than" and so on. Sitecore comes with all of the built-in support you need to create this type of condition as it is used in many of the standard rules already.

This is the condition that counts how many child items an item has

using Sitecore.Rules;

namespace MyNamespace
{
    public class AnyChildItemCountCondition<T> : BaseOperatorCondition<T> where T : RuleContext
    {
        public int Count { get; set; }

        protected override bool Execute(T ruleContext)
        {
            int childCount = ruleContext.Item.Children.Count;

            bool isValid = base.IsMatch(childCount, Count);

            return isValid;
        }
    }
}

The "Count" variable is going to be populated by the rules engine and we'll cover how that is configured later on. So if the condition is "where the number of child items is greater than 5" then "Count" will be 5. So in the Execute method we simply have to get the number of child items for the item the rule is applied to and see if the operator condition matches.

This is the condition that counts how many children of a certain template there are. It's very similar to the example above, only now we need to ensure we only count items of the desired template.

using MyNamespace.Extensions;
using Sitecore.Data;
using Sitecore.Rules;
using System;
using System.Linq;

namespace MyNamespace
{
    public class TemplateChildItemCountCondition<T> : BaseOperatorCondition<T> where T : RuleContext
    {
        public int Count { get; set; }
        public Guid TemplateID { get; set; }

        protected override bool Execute(T ruleContext)
        {
            ID id = new ID(TemplateID);

            int childCount = ruleContext.Item.Children.Count(i => i.IsDerivedFromTemplate(id));
            
            bool isValid = base.IsMatch(childCount, Count);

            return isValid;
        }
    }
}

As before, both "Count" and "TemplateID" are going to be populated by the rules engine. IsDerivedFromTemplate is an extension method

public static bool IsDerivedFromTemplate(this Item item, ID templateId)
{
    if (item == null) return false;

    if (templateId.IsNull) return false;

    var templateItem = item.Database.Templates[templateId];
    var returnValue = false;

    if (templateItem != null)
    {
        var template = TemplateManager.GetTemplate(item);
        if (template != null
            && (template.ID == templateItem.ID || template.DescendsFrom(templateItem.ID)))
        {
            returnValue = true;
        }
    }
    return returnValue;
}

Finally the condition that counts how many siblings there are of a certain template

using MyNamespace.Extensions;
using Sitecore.Data;
using Sitecore.Rules;
using System;
using System.Linq;

namespace MyNamespace
{
    public class TemplateSiblingItemCountCondition<T> : BaseOperatorCondition<T> where T : RuleContext
    {
        public int Count { get; set; }
        public Guid TemplateID { get; set; }

        protected override bool Execute(T ruleContext)
        {
            ID id = new ID(TemplateID);

            int siblingCount = ruleContext.Item.Parent.Children.Count(i => i.IsDerivedFromTemplate(id));
            
            bool isValid = base.IsMatch(siblingCount, Count);

            return isValid;
        }
    }
}

Configuring the custom conditions

Now that we have the implementations for our conditions we need to configure them in the rules engine. The rules are in logical groups and our conditions are going to be added to the "Item Hierarchy" group which is found here

/sitecore/system/Settings/Rules/Definitions/Elements/Item Hierarchy

First up is the "AnyChildItemCountCondition" condition. Create a new "Condition" under "Item Hierarchy" called "Has Child Items" and configure it like so



The "Text" field is what appears in the rule engine front end and the "Type" contains the type reference to the implementation. The format of the Text field is already well documented but I'll give a quick explanation here.

where the number of child items [operatorid,Operator,,compares to] [count,Integer,,number]

When viewed in the rules engine the above text functions like this



If you click the "compares to" text you are shown the following options



Let's look at the field configuration piece by piece

[operatorid,Operator,,compares to]

The first parameter "operatorid" is the name of the variable in your implementation class that will receive the selected value. In this instance that value is going to be "greater than", "equal to" and so on. This variable is in the OperatorCondition class that our custom conditions inherit from so there is no need to explicitly define it in our own class.

The second parameter "Operator" dictates what kind of pop-up menu we get. This refers to an item under the node

/sitecore/system/Settings/Rules/Definitions/Macros

So here we are stipulating to use

/sitecore/system/Settings/Rules/Definitions/Macros/Operator

As already mentioned, this is built in to Sitecore and allows us to select from one of the pre-defined operators found here

/sitecore/system/Settings/Rules/Definitions/Operators

The third parameter in our example is empty, however it is a piece of custom data you can pass to the previously mentioned macro. Not all macros need this data, but if you are using the Tree macro, for example, then this parameter will contain the root node for your tree.

The fourth parameter "compares to" contains the text to display when no option has been selected.

Next we'll create the condition for TemplateChildItemCountCondition which counts how many child items of a given template there are. Create a Condition under Item Hierarchy called "Has Template Child Items" and configure it like this



This one is a little more complicated and in the rules engine it will look like this



This is the Text definition which contains three variables.

where the number of [templateid,Tree,root=/sitecore/templates,specific template] child items [operatorid,Operator,,compares to] [count,Integer,,number]

The first variable allows us to select a template. The first parameter "templateid" is the variable that will contain the GUID of the selected template and this variable is defined in TemplateSiblingItemCountCondition. The second parameter is "Tree" which means we use the tree selector. The third parameter defines where we want our tree to start. The fourth parameter contains the default text to use. So when we click the words "specific template" when defining the condition we see this



The dialog will show you the root of your Templates folder where you can select your desired template.

The second variable "[operatorid,Operator,,compares to]" is the same as the previous condition. The third variable "[count,Integer,,number]" allows us to select a number. So "count" is the variable this number is stored in and that is defined on the TemplateChildItemCountCondition class. "Integer" is the name of the macro to use and "number" is the text to display. When "number" is clicked on the user is presented with a dialog where they enter the desired number



The third and final condition is called "Has Template Sibling Items" and is configured like this



So now when we create a rule using the rules engine we should see our new rules in the Item Hierarchy section along with the built-in rules.



Creating the custom actions

Now we need to create our two actions, one to delete the item and one to cancel an item move. First the delete action

using Sitecore.Data.Events;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Rules;
using Sitecore.Rules.Actions;
using Sitecore.SecurityModel;

namespace MyNamespace
{
    public class DeleteItemAction<T> : RuleAction<T> where T : RuleContext
    {
        public override void Apply(T ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            Item item = ruleContext.Item;
            if (item == null)
            {
                return;
            }

            using (new SecurityDisabler())
            using (new EventDisabler())
            {
                item.Delete();
            }

            Sitecore.Context.ClientPage.ClientResponse.Alert(string.Format("The maximum number of {0} items in this location already exist", item.Template.Name));
        }
    }
}

And the cancel move action

using Sitecore.Data.Events;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Rules;
using Sitecore.Rules.Actions;
using Sitecore.SecurityModel;

namespace MyNamespace
{
    public class CancelMoveAction<T> : RuleAction<T> where T : RuleContext
    {
        public override void Apply(T ruleContext)
        {
            Assert.ArgumentNotNull(ruleContext, "ruleContext");
            Item item = ruleContext.Item;
            if (item == null)
            {
                return;
            }

            if (!ruleContext.Parameters.ContainsKey("OldParentID"))
            {
                return;
            }

            string oldID = ruleContext.Parameters["OldParentID"] as string;

            if (string.IsNullOrWhiteSpace(oldID))
            {
                return;
            }

            Item target = item.Database.GetItem(oldID);
            if (target == null)
            {
                return;
            }

            using (new SecurityDisabler())
            using (new EventDisabler())
            {
                item.MoveTo(target);
            }

            Sitecore.Context.ClientPage.ClientResponse.Alert(string.Format("The maximum number of {0} items in this location already exist", item.Template.Name));
        }
    }
}

The above action is going to execute after the item has been moved, so it doesn't really cancel the item move, it reverses it by moving the item back to its original location. It gets the previous parent via a parameter called OldParentID, and this is a parameter we will set ourselves in code we will get to later on.

We need to configure these in a similar way to how we configured the conditions. We'll create a new group for these rules so create a new "Element Folder" called "My Item Actions" under

/sitecore/system/Settings/Rules/Definitions/Elements

It's going to end up looking like this



So create an "Action" inside "My Item Actions" called "Cancel Item Move" and configure it like so



Next an "Action"hn called "Delete Item"



Define a custom Tag

Now we need to create a custom Tag for our custom actions. Tags are used to define what groups of conditions or actions are shown under which circumstances. As we added our custom conditions to an existing group (Item Hierarchy) they are shown wherever that group is shown as the tags for that group had already been configured as part of the standard Sitecore installation. As our actions are in a new group called "My Item Actions" we need to create a tag for that group so we can configure where the group is shown. This is very easy, just create a tag called "My Item Actions" under

/sitecore/system/Settings/Rules/Definitions/Tags



When you create the Tag item it will add the "Visibility" item for you by default. Now we have to associate that tag with our custom action group so edit this item

/sitecore/system/Settings/Rules/Definitions/Elements/My Item Actions/Tags/Default

and add "My Item Actions" to the Tags field



The list you see in the "Tags" field comes from "/sitecore/system/Settings/Rules/Definitions/Tags" and "My Item Actions" is in the list due to us just creating the tag in the previous step. Now whenever the "My Item Actions" tag is used it refers to our "My Item Actions" action group.

Define custom rule context folders

Now we have all of our operators and actions written and configured the next step is to create rule folders . A rule folder contains related rules, so we are going to create a folder for Item Created events and a folder for Item Moved events and we will put all of the rules we want to execute for those events in the respective folder. This means that you can have different rules for different item types, so you might create a rule for items of template "Sub Category" that ensures you don't have more than 10 Sub Category items below a Category item, and you might have a separate rule that ensures you don't have more than 5 Menu Items under a Menu.

Rules folders exist under

/sitecore/system/Settings/Rules

So create a "Rules Context Folder" called "My Created Events". That will create a basic item structure that includes a Rules item and a Tags item. Select the Tags/Default item and in the Taxonomy section you'll see a Tags field and this is where we define which groups of operators and actions our rules will use. You can choose what you want here but at a minimum you're going to want Item Hierarchy and My Item Actions, possibly Item Information as well.



We also need a "Rules Context Folder" for moved events. This is going to be identical to "My Created Events" so simply duplicate "My Created events" and call it "My Moved Events". You should now have your two events folders like so


Create the rules

Now we can create a rule for our menu items, so under "/sitecore/system/Settings/Rules/My Created Events/Rules" create a new Rule called "Menu Created". When you edit the rule you will see all of the groups we selected in the tags, so that will be Item Hierarchy and Item Information in the conditions section and My Item Actions in the actions section.



Now configure the "Menu Created" rule as below



Here we are saying that when an item is created, if the item being created is a Navigation Section and there are more than 5 navigation sections then delete the item just created. Note that this runs after the item has been created so if we only want 5 Navigation Section items when the item is created there will be 6 which is why we check for greater than 5 items.

This rule works but in case you hadn't already noticed it will apply to Navigation Section items created anywhere. If you wanted to you could make these rules more granular by specifying that if the parent item is of a certain template then allow a certain number. Conditions that query the parent item template are in the rules you get out of the box, eg "where the parent template is specific template". Once we have finished with this initial simple example I'll show an example that's a bit more complex later on.

Next up we create the rule for when an item is moved so create that under "/sitecore/system/Settings/Rules/My Moved Events/Rules" and configure it like this


When you're finished the rules folders will look like this


Trigger the rules

Now we have all of our rules written and configured, the final step is to make sure these rules are triggered when items are moved or created. To do this we'll create a class that triggers these rules and hook that code into the respective item events.

This is the RunRules class;

using Sitecore.Data;
using Sitecore.Data.Events;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.Rules;
using System;
using System.Collections.Specialized;

namespace MyNamespace
{
    public class RunRules
    {
        public void OnCreated(object sender, EventArgs args)
        {
            var itemArgs = Event.ExtractParameter(args, 0) as ItemCreatedEventArgs;

            if (itemArgs == null || itemArgs.Item == null)
            {
                return;
            }

            Execute(itemArgs.Item, "/sitecore/system/Settings/Rules/My Created Events/Rules/*[@@templatename='Rule']");
        }

        public void OnMoved(object sender, EventArgs args)
        {
            Item item = Event.ExtractParameter(args, 0) as Item;
            ID oldParentID = Event.ExtractParameter(args, 1) as ID;

            if (item == null)
            {
                return;
            }

            Execute(item, "/sitecore/system/Settings/Rules/My Moved Events/Rules/*[@@templatename='Rule']",
                new NameValueCollection { { "OldParentID", oldParentID.ToString() } });
        }

        private void Execute(Item item, string query, NameValueCollection parameters = null)
        {
            Item[] ruleItems = item.Database.SelectItems(query);

            if (ruleItems.Length == 0)
            {
                return;
            }

            var ruleContext = new RuleContext();

            ruleContext.Item = item;

            if (parameters != null)
            {
                foreach (string key in parameters.AllKeys)
                {
                    ruleContext.Parameters.Add(key, parameters[key]);
                }
            }

            foreach (var ruleItem in ruleItems)
            {
                var rules = RuleFactory.ParseRules<RuleContext>(ruleItem.Database, ruleItem["Rule"]);

                if (rules != null && rules.Count > 0)
                {
                    rules.Run(ruleContext);
                }
            }
        }
    }
}

We're hard-coding the paths to our custom rules folders but you can get that path from a config if you want. All the code does is get all Rule items in the respective folder and programmatically execute them. Additionally the moved event supplies an "OldParentID" parameter which is used my our custom "Cancel Item Move" action (revise the code for the CancelMoveAction class above to see where this is used).

Next we hook this class up to the relevant item events by creating a config patch file like below

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <events>
      <event name="item:moved">
        <handler type="MyNamespace.RunRules, MyAssembly" method="OnMoved"/>
      </event>
      <event name="item:created">
        <handler type="MyNamespace.RunRules, MyAssembly" method="OnCreated"/>
      </event>
    </events>
  </sitecore>
</configuration>

Call the file something sensible like "CustomEvents.xml" and put it in the App_Config/Include folder.

Testing the code

We now have everything in place (finally) so let's test our rule.  The rule says we shouldn't be able to create more than 5 "Navigation Section" items in the same location.  Here I've set up a sample menu with 5 existing sections



When I try and create a new one




Now let's try moving an item from a different location into this one.




So we can try a bit more of an advanced rule such as below where we specify the parent item must be of a certain template and that want any parent item with the word "Expanded" in its name to be excluded from the rule



As we see from the image above I am still resticted to 5 sections under "Simple Menu" but I can have more under "Expanded Menu" due to the word "Expanded" being in the item nane.

Summary

I've seen simpler code samples that achieve this effect by building logic into the custom item events themselves, however that solution acts more like a black box; the functionality is rigid and not easily changed.  This solution where we run rules when those events are triggered and use those rules to determine if an item can be created or not is extremely configurable.  As new items are created that need restrictions then we simply need new rules for them.  We can also configure different rules for different items, and it is all done via the content editor, you don't need to change a single line of code.

Into the bargain we've also created some custom rules that can be used to control insert options in a far more powerful manner (there are built-in rules that allow you to configure insert options in case you didn't know).

This system still isn't fool-proof, for example I could create an item of one template then change the template to a different template and that won't trigger our rules, and there are other ways around this too that I won't go into but they require a hardened attempt to bypass the system and this system is really only aimed at standard content editing.

No comments:

Post a Comment