Pages

Monday, March 29, 2021

ContentArea with Groups of Personalized Content

Personalization is a powerful component in Episerver that can provide a cool, fresh, and tailored experience for users that visit your site. Leveraging different criteria and conditions in Epi, Visitor Groups provide a grouping mechanism for users to be served different content based on pages they've viewed, forms they've submitted, or campaigns they have arrived from, among various other criteria. It's a pretty nice piece of functionality that, if you haven't learned about yet, you should check out more about, here.

That said, Personalized Groups in Episerver serve content on a prioritized, top-down, first-match basis. That means a visitor is served the first matching content item that is tagged with a visitor group they're in. That also means you are limited to one piece of changing content per Personalized Group for a user. But I needed more, and here are the solutions I explored.

Some Backstory

While the Content Area in Episerver supports Personalized Groups for personalizing content, each group only displays the first matching item for a user. For example, you can add 2 different content items for VisitorA, 3 for VisitorB, and 4 for Everyone Else, but only the first matching item will display. It's prioritized from the top down, meaning it matches in the order the Visitor Groups are stacked in the Personalized group, just like all Content Items in a Content Area are processed. You can reorder them to make a different item appear first, but it's still only going to display the first item.  

Sometimes, though, you might want to display multiple items per visitor group, similar to this post on Epi World: Personalized group with list of blocks? In this case, the desired functionality would be the following: 

This is an example of the idea, but this visual isn't possible without more work.

2 items are added for VisitorA, 3 for VisitorB, and 4 for Everyone Else. When VisitorA visits the page, they are met with the 2 items identified for them. VisitorB would see the 3 tagged for them when hitting the page. And if someone viewed the page and didn't match one of those 2 groups, they would see the 4 marked for Everyone Else. 

There are a couple ways to accomplish this.

A Blocky Solution

One approach to solving this problem is to create a "Personalization Group Block" to put grouped content in. The idea behind this approach is to create a block type with a single ContentArea property to contain the content you want displayed for a specific Visitor Group.  

Given the 3 groups, VisitorA, VisitorB, and Everyone Else, you create 3 different blocks, each containing the content to display for their targeted Visitor Group. For ease of management, name each block according to the group criteria it matches and put all the items specific to the targeted group into the ContentArea. Then add each of the 3 "grouping" blocks into the ContentArea on your page. Personalize each of those blocks to match them to the Visitor Group you want their content to appear for. Stack them in priority order, publish the blocks and the page, and you have a group of blocks that will appear whenever a user matching one of their Visitor Groups hits the page. 


This approach still requires some extra development, but it's not as "in-depth" as the other solution below, so it might be less intimidating. It just requires a simple model and a view for it that doesn't need anything other than a PropertyFor statement. It's fairly quick to throw something like that together. 

[pre class="brush:csharp;class-name:'collapse-box'" title="Personalization Group Block model"]
   [ContentType(DisplayName = "Personalization Group Block", GUID = "a91929d8-677a-4b73-907e-d6726290f056",
        Description = "This block is used to group together other blocks for Personalization in a Content Area.")]
    [ImageUrl("~/Static/img/Personalization Block.png")]
    public class PersonalizationGroupBlock : BlockData
    {
        [CultureSpecific]
        [Display(
            Name = "Content Items",
            Description = "The content items you want to display for this group when personzalized.",
            GroupName = SystemTabNames.Content,
            Order = 1)]
        public virtual ContentArea ContentItems { get; set; }
    }
[/pre]

However, there are a couple drawbacks to this. 

First, it's a bit clunky to create the separate blocks just for personalization. This nesting alone introduces a few negatives.

  1. If you want to reorder the grouped items, you have to go into the grouping block. 
  2. If you want to edit one of the grouped items, you also have to go into the grouping block first. 
  3. The nesting creates added markup around the blocks that you might have to handle with additional CSS.

Second, if you are in the All Properties view it's a bit difficult to identify what is being shown for each Visitor Group. Though, to be fair, you can still use the Preview mode set to a specific Visitor Group for a quick In-Page look.

Overall, it's an approach that might work for some people, and might be enough. For others it might be a bit too "blocky". The next solution allows you to manage things more from the ContentArea on the parent page or block, and in a more natural Personalized Group approach.

Render it Differently

A different solution is to build your own ContentAreaRenderer. This approach allows you to manage the Personalized Content all from the parent ContentArea instead of nesting blocks. This eliminates the layering issues of the above solution. It also makes it easier to reorder items and see what is assigned to each Visitor Group. 

The downside to this approach, aside from the added development, is the ContentArea display can be a bit confusing. Without writing additional Dojo code to group the items in the ContentArea when in edit mode, each item will show a Visitor Group assigned to it and will take up it's own chunk of space. 

If you want to jump straight to the code for this solution, check it out here: CustomContentAreaRenderer sample. If you want a more detailed walk through the code, keep reading.

Also, don't forget to register the ContentAreaRenderer with your Epi solution. To do this you can create a separate InitializationModule, or add the Configure line to one you already have.

[pre class="brush:csharp;class-name:'collapse-box'" title="CustomContentAreaRendererInit initialization module"]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class CustomContentAreaRendererInit : IConfigurableModule
    {
        public void ConfigureContainer(ServiceConfigurationContext context)
        {
            context.StructureMap().Configure(c => c.For<ContentAreaRenderer>().Use<CustomContentAreaRenderer>());
        }

        public void Initialize(InitializationEngine context) { }

        public void Uninitialize(InitializationEngine context) { }
    }
[/pre]

Stepping Through The Code

The ContentAreaRenderer contains the logic for handling the ContentArea as a whole, and how to process which items to display. It's fairly easy to create your own, and there are several examples in the Episerver Community already, like the Bootstrap supporting one, or the one in the Alloy MVC template. It's a bit more work to implement than the block solution above, but it's not complicated.

The default behaviour for Personalization Groups comes from the FilteredItems method when the RenderContentAreaItems method is called. To make a new ContentAreaRenderer where content items are grouped according to their assigned Visitor Groups, a new filter method is needed. 

The new filter method needs to:

  1. Maintain the order items appear in the Content Area
  2. Handle each Personalization Group individually
  3. Group Personalized items according to their Visitor Group, and in order
  4. Stop matching items in a Personalization Group after:
    1. matching a Visitor Group
    2. falling back to the "Everyone Else" condition if it exists
  5. Handle items not in Personalization Groups

To start, create a class called CustomContentAreaRenderer and inherit from the default renderer. The VisitorGroupRoleRepository will be needed later, so it's good to account for that with the constructor. For a smidge of performance, I've added Lazy initialization of the VisitorGroup list in case it's not needed.

[pre class="brush:csharp;class-name:'collapse-box'"]
public class CustomContentAreaRenderer : ContentAreaRenderer
{
    private readonly IVisitorGroupRoleRepository _visitorGroupRoleRepo;
    private Lazy<IEnumerable<VisitorGroup>> _visitorGroups = new Lazy<IEnumerable<VisitorGroup>>(() => (ServiceLocator.Current.GetInstance<IVisitorGroupRepository>()).List());

    public CustomContentAreaRenderer() : base()
    {
        _visitorGroupRoleRepo = ServiceLocator.Current.GetInstance<IVisitorGroupRoleRepository>();
    }

    public CustomContentAreaRenderer(IVisitorGroupRoleRepository visitorGroupRoleRepository, IContentRenderer contentRenderer, TemplateResolver templateResolver,
        IContentAreaItemAttributeAssembler attributeAssembler, IContentRepository contentRepository, IContentAreaLoader contentAreaLoader)
        : base(contentRenderer, templateResolver, attributeAssembler, contentRepository, contentAreaLoader)
    {
        _visitorGroupRoleRepo = visitorGroupRoleRepository;
    }

    public override void Render(HtmlHelper htmlHelper, ContentArea contentArea)
    {
        // the majority of this block is boilerplate from Episerver's renderer
        if (contentArea == null || contentArea.IsEmpty) { return; }

        TagBuilder contentAreaTagBuilder = null;
        var viewContext = htmlHelper.ViewContext;

        if (!IsInEditMode(htmlHelper) && ShouldRenderWrappingElement(htmlHelper))
        {
            contentAreaTagBuilder = new TagBuilder(GetContentAreaHtmlTag(htmlHelper, contentArea));
            AddNonEmptyCssClass(contentAreaTagBuilder, viewContext.ViewData["cssclass"] as string);
            viewContext.Writer.Write(contentAreaTagBuilder.ToString(TagRenderMode.StartTag));
        }
        RenderContentAreaItems(htmlHelper, GetFilteredItems(contentArea));  // the change to use GetFilteredItems gives us better control of personalized content references
        if (contentAreaTagBuilder != null)
        {
            viewContext.Writer.Write(contentAreaTagBuilder.ToString(TagRenderMode.EndTag));
        }
    }
}
[/pre]

The Render method is almost identical to the built in method, but it calls a new GetFilteredItems method where the core of everything happens. There is quite a bit going on in there and I will detail that later.

Support Methods

First, there are a couple methods to point out in this class, plus a custom class for making it easier to process the items in the ContentArea. 

The filterVisitorGroupIds method contains the logic for identifying which Visitor Groups a user is member of. A Linq Join statement maintains order based on their occurrence in the Personalization Group. The first matching group is taken, or the value is nulled which makes it easier to fallback to "Everyone else" later.

[pre class="brush:csharp;class-name:'collapse-box'" title="filterVisitorGroupIds method"]
    /// <summary>
    /// Return only visitor group IDs the current user is a member of
    /// </summary>
    /// <param name="visitorGroupIds">The list of IDs to filter</param>
    /// <returns>A list of VisitorGroup IDs as strings</returns>
    private List<string> filterVisitorGroupIds(IEnumerable<string> visitorGroupIds)
    {
        var visitorGroups = from id in visitorGroupIds
                            join vg in _visitorGroups.Value
                            on id equals vg.Id.ToString()
                            select vg;
        var filteredGroupIds = new List<string>();
        foreach (var visitorGroup in visitorGroups)
        {
            if (_visitorGroupRoleRepo.TryGetRole(visitorGroup.Name, out var roleProvider) && roleProvider.IsInVirtualRole(PrincipalInfo.CurrentPrincipal, null))
            {
                filteredGroupIds.Add(visitorGroup.Id.ToString());
            }
        }
        return filteredGroupIds;
    }
[/pre]

Next, the check for Read permissions on the items is handled by the shouldUserSeeContent method.

[pre class="brush:csharp;class-name:'collapse-box'"]
    /// <summary>
    /// Check for access rights to view a specific ContentFragment
    /// </summary>
    /// <param name="fragment">The ContentFragment to check access rights against. This applies to Visitor Groups as well.</param>
    /// <returns>True or False based on whether the current user has Read access</returns>
    private bool shouldUserSeeContent(ContentFragment fragment)
    {
        var securable = fragment as ISecurable;
        if (securable == null) { return true; }

        var securityDescriptor = securable.GetSecurityDescriptor();
        return securityDescriptor.HasAccess(PrincipalInfo.CurrentPrincipal, AccessLevel.Read);
    }
[/pre]

I also have an extension method named NullIfEmpty that makes it easier to fallback to an inline condition for Linq statements. This extension is used in the last block of code when getting the items for the matching Visitor Group ID, or the "Everyone Else" items. 

[pre class="brush:csharp;class-name:'collapse-box'" title="NullIfEmpty extension"]
    public static IEnumerable<T> NullIfEmpty<T>(this IEnumerable<T> source)
    {
        if (source == null || !source.Any()) { return null; }
        return source;
    }
[/pre]

Finally, to make things easier to work with, I created a custom detail class that parses the ContentFragment into a usable object. I use the HTMLAgility pack to make it easy to parse the XHTML fragment string into usable detail.

[pre class="brush:csharp;class-name:'collapse-box';" title="ContentAreaItemDetail class"]
    /// <summary>
    /// Represents a Content Area Item with more detail, like their visitor group assignments.
    /// </summary>
    private class ContentAreaItemDetail
    {
        /// <summary>
        /// Gets the content group ID referenced by the Content Fragment
        /// </summary>
        public string ContentGroup
        {
            get
            {
                return this.ContentFragment.ContentGroup;
            }
        }
        /// <summary>
        /// All attributes provided as part of this item. These contain references to the Group ID and Visitor Group IDs assigned for personalization.
        /// </summary>
        public Dictionary<string, string> FragmentAttributes { get; set; } = new Dictionary<string, string>();
        /// <summary>
        /// The Content Fragment associated with this content item.
        /// </summary>
        public ContentFragment ContentFragment { get; private set; }
        /// <summary>
        /// The Content ID of the item as a GUID
        /// </summary>
        public Guid ItemGuid { get; private set; }
        /// <summary>
        /// A collection of Visitor Group IDs assigned to this item
        /// </summary>
        public List<string> VisitorGroupIds { get; set; } = new List<string>();

        public ContentAreaItemDetail(ContentFragment fragment)
        {
            this.ContentFragment = fragment ?? throw new ArgumentNullException(nameof(fragment));
            this.ItemGuid = fragment.ContentGuid;
            // we need the reference to the Visitor Group IDs assigned to this item, but they are only available in the InternalFormat HTML markup
            // so we need to access the attribute in the markup to get the values
            var fragmentElement = HtmlNode.CreateNode(fragment.InternalFormat); // create HTML Node to access attributes cleanly
            this.FragmentAttributes = fragmentElement.Attributes.ToDictionary(k => k.Name.ToString(), v => v.Value);    // dictionary makes future operations a bit easier and normalized
            if (FragmentAttributes.TryGetValue(FragmentHandlerHelper.GroupsAttributeName, out var groupIds))    // groups attribute defines the Visitor Groups assigned to this item
            {
                // each Visitor Group ID is added as a comma delimited string value to the Internal Format HTML
                this.VisitorGroupIds = groupIds.Split(',').ToList();
            }
        }
    }
[/pre]

Main Functionality

As mentioned, the filtering of the items happens in a method called GetFilteredItems. Because we need reference to the Visitor Groups assigned to a ContentArea item, each item first needs to be handled as a ContentFragment. This is a more detailed representation of a ContentAreaItem. Each ContentFragment contains additional information about the item in the InternalFormat property as an XHTML string, including Visitor Group assignments stored as attributes. 

To make grouping and filtering easier, a new detail object is created for each item, making that additional information easier to use.

[pre class="brush:csharp;class-name:'collapse-box'"]
    private IEnumerable<ContentAreaItem> GetFilteredItems(ContentArea contentArea)
    {
        var itemList = new List<ContentAreaItem>();
        // get all content items as ContentAreaItemDetail to iterate through
        var detailedContentItems = contentArea.Fragments.Where(f => f as ContentFragment != null).Select(f => new ContentAreaItemDetail(f as ContentFragment)).ToList();

        /* code continues */
    }
[/pre]

Next, I iterate the detail list to check whether each item should be displayed. The approach used allows me to remove matches from the collection as I go, and still keeps things clean. 

If an item is part of a Personalization Group, the ID of the Personalization Group is stored in the item's ContentGroup property. The first condition checks for that value. If it's empty, the item isn't personalized so read permission is checked for the current user, and the item is added to the list of items to display if it passes.

[pre class="brush:csharp;class-name:'collapse-box'"]
    // for loop so we can manipulate the list and remove items as we process them. this prevents us from having to iterate every item
    for (ContentAreaItemDetail fragmentItem; (fragmentItem = detailedContentItems.FirstOrDefault()) != null;)
    {
        // we can tell if the current item is a personalized group by checking the ContentGroup value. all personalized items reference a ContentGroup ID.
        if (string.IsNullOrWhiteSpace(fragmentItem.ContentGroup))
        {
            // add if user has permission and remove from detail list to prevent duplicates
            if (shouldUserSeeContent(fragmentItem.ContentFragment))
            {
                itemList.Add(new ContentAreaItem(fragmentItem.ContentFragment));
            }
            detailedContentItems.Remove(fragmentItem);
        }
        else
        {
            var groupItems = detailedContentItems.Where(i => i.ContentGroup == fragmentItem.ContentGroup);
            // get the first visitor group ID the user is part of
            var firstMatchID = filterVisitorGroupIds(groupItems?.SelectMany(i => i.VisitorGroupIds)?.Distinct()).FirstOrDefault();
         
            /* code continues */
        }
    }
[/pre]

The else condition matches if the item is in a Personalization Group. Inside this condition the detail list is used to group all items matching this Personalization Group ID, referenced as the ContentGroup property. All the Visitor Group IDs are then grouped and filtered to the first one the current user is a member of.

The final lines of code collect all the items matching that Visitor Group ID. If the user is not part of a Visitor Group the list will be null, and all items with no group assigned are collected as the "Everyone Else" fallback.

[pre class="brush:csharp;class-name:'collapse-box'"]
    // get all items matching our first matched visitor group ID or all items set for "Everyone else sees"
    var visitorGroupItems = (
            groupItems?.Where(i => i.VisitorGroupIds.Contains(firstMatchID)).NullIfEmpty()
            ?? groupItems.Where(i => !i.VisitorGroupIds.Any())  // the "Everyone else sees" items don't have Visitor Group IDs added to them
        )?.Where(i => shouldUserSeeContent(i.ContentFragment));
    // add any matches to our list and remove all group items to continue iterating through content area items
    if (visitorGroupItems?.FirstOrDefault() != null)
    {
        itemList.AddRange(visitorGroupItems.Select(i => new ContentAreaItem(i.ContentFragment)));
    }
    detailedContentItems.RemoveAll(i => i.ContentGroup == fragmentItem.ContentGroup);
[/pre]

Any matching items are then added to the list of items to render. Afterwards, all items in this ContentArea that are part of this Personalization Group are removed from the detail list and the loop continues the same process through the rest of the list items until complete.

The Result




No comments:

Post a Comment

Share your thoughts or ask questions...