Monday, March 9, 2020

Extending Sitecore Scriban to handle User objects

Sitecore SXA & Scriban

As from SXA 9.3 we can use Scriban templates in the variants of SXA. Out of the box, Sitecore provided us with a few extensions that allow us to get some context information and all sorts of stuff on items. We can also fetch item fields and so on...

All of this is very nice and actually rather complete for most cases, but we can still easily write our own extensions.

Access to a User object 

During the 2020 hackathon, we created a Scriban extension to display user information. I tried to return User objects and fetch the properties from within the Scriban template as that would be awesome and very flexible - but it didn't work. Limited in time, I decided to just return the full name of the user instead. But I was curious.. how does this work with Items? So, as any good Sitecore developer, I asked the question on Sitecore StackExchange.
How can I get the user object (or any other object) in the Scriban templates?
Dawid (from the always helpful SXA team) told me it was possible. Not easy (his words), but it could be done - that sounds like a challenge to me 😛

Challenge accepted. And yes, challenge succeeded. Otherwise this would be a silly blog post...

Object Accessor

First step in the process is an object accessor - a UserAccessor in our case - that has the logic to access the properties of the object from within Scriban. It's a class that implements Scriban.Runtime.IObjectAccessor.
using System;
using System.Collections.Generic;
using System.Linq;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Sitecore.Security;
using Sitecore.Security.Accounts;

public class UserAccessor : IObjectAccessor
{
  public int GetMemberCount(TemplateContext context, SourceSpan span, object target)
  {
    return GetMembers(context, span, target).Count();
  }

  public IEnumerable<string> GetMembers(TemplateContext context, SourceSpan span, object target)
  {
    var user = target as User;
    if (user == null)
    {
      return Enumerable.Empty<string>();
    }

    var properties = typeof(UserProfile).GetProperties().Where(p => p.GetType().IsValueType);
    var result = properties.Select(p => p.Name);
    return result;
  }

  public bool HasMember(TemplateContext context, SourceSpan span, object target, string member)
  {
    return true;
  }

  public bool TryGetValue(TemplateContext context, SourceSpan span, object target, string member, out object value)
  {
    value = string.Empty;
    var user = target as User;
    if (user == null)
    {
      return false;
    }

    try
    {
      var property = typeof(UserProfile).GetProperty(member);
      if (property == null)
      {
        return false;
      }

      value = property.GetValue(user.Profile, null);
      return true;

    }
    catch
    {
      return false;
    }
  }

  public static string ToString(User user)
  {
    return user.Profile.FullName;
  }

  public bool TrySetValue(TemplateContext context, SourceSpan span, object target, string member, object value)
  {
    throw new InvalidOperationException("Unable to change user properties during the rendering process");
  }
}

What we have here:

  • GetMemberCount: return the number of members (see next function)
  • GetMembers: I want to be able to use all value typed properties (string and such) of a users profile. After checking if the object actually is a user, we fetch all those properties from the "UserProfile" type and return their names.
  • HasMember: as we have members, this is just true
  • TryGetValue: this function tries to get the value for the given member from the object. We first verify the object is a User. Then we try to get the property named "member" from the UserProfile type. If the property exists (note this is case sensitive) we can get the value of that property from the given object (User) - in our case it's the user.Profile and not the user itself. The return value indicates whether the value was found or not - we return an empty string on all failures.
  • ToString: function to determine what happens if the object is used without a member in the Scriban template - see later in the Context
  • TrySetValue: we don't allow set functionality

Template Context

In the template context all accessors are registered. We inherit from the current Sitecore.XA.Foundation.Scriban.ContextExtensions.SitecoreTemplateContext and override a few functions:
using Scriban.Parsing;
using Scriban.Runtime;
using Sitecore.Security.Accounts;
using Sitecore.XA.Foundation.Mvc.Models;
using Sitecore.XA.Foundation.Scriban.ContextExtensions;

public class GatoTemplateContext : SitecoreTemplateContext
{
  protected UserAccessor UserAccessor { get; }

  public GatoTemplateContext(RenderingWebEditingParams webEditingParams) : base(webEditingParams)
  {
    UserAccessor = new UserAccessor();
  }

  protected override IObjectAccessor GetMemberAccessorImpl(object target)
  {
    return target is User ? UserAccessor : base.GetMemberAccessorImpl(target);
  }

  public override string ToString(SourceSpan span, object value)
  {
    return value is User user ? UserAccessor.ToString(user) : base.ToString(span, value);
  }
}

The three functions that we override:

  • Constructor: we call the base constructor and initialize the UserAccessor property
  • GetMemberAccessorImpl: this method decides which accessor to use. We check if the target object is a User - if so we return the UserAccessor, if not we call the base method.
  • ToString: the general ToString method - we need to check the target object and if that is a User we call the ToString we created in the UserAccessor, otherwise we call the base method.

InitializeScribanContext

Next and last step is a processor to set our template context. The first call in the generateScribanContext pipeline is the current Scriban context initializer. We create a new version of this processor - implementing Sitecore.XA.Foundation.Scriban.Pipelines.GenerateScribanContext.IGenerateScribanContextProcessor.
using Scriban.Runtime;
using Sitecore.XA.Foundation.Scriban.Pipelines.GenerateScribanContext;

public class InitializeScribanContext : IGenerateScribanContextProcessor
{
  public void Process(GenerateScribanContextPipelineArgs args)
  {
    args.GlobalScriptObject = new ScriptObject();
    args.TemplateContext = new GatoTemplateContext(args.RenderingWebEditingParams);
  }
}

This processor sets our template context - we have to configure this processor instead of the existing one:
<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/" xmlns:search="http://www.sitecore.net/xmlconfig/search/">
  <sitecore>
    <pipelines>
      <generateScribanContext>
        <processor type="Feature.Attendees.Subscribers.InitializeScribanContext, Feature.Attendees" patch:instead="*[@type='Sitecore.XA.Foundation.Scriban.Pipelines.GenerateScribanContext.InitializeScribanContext, Sitecore.XA.Foundation.Scriban']" resolve="true" />
      </generateScribanContext>
    </pipelines>
  </sitecore>
</configuration>

And that's is for code. This example used the Sitecore User object, but we could enable other objects the same way.


Scriban template

To use this in a Scriban template, you need a function that returns one or more User objects. Such a function is out-of-scope for this post, but you can easily create that with a custom Scriban extension.
<div>
<ul>
{{for i_user in (sc_subscribers i_page)}}
  <li>{{i_user}} - {{i_user.Email}} - {{i_user.Portrait}}</li>
{{end}}
</ul>
</div>

The sc_subscribers function in the example is a custom function that returns users. As you can see we can then write {{i_user}} (this will use the ToString method) and also things like {{i_user.Email}} to get the profile properties of a user.

Pretty cool stuff.. making the Scriban templates even more useful!


from Cat GIFs via Gfycat


No comments:

Post a Comment