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


Thursday, March 5, 2020

Sitecore Hackathon 2020: The BeeGhentle story

Sitecore hackathon

February 29 - 02:00 AM local time. Way too early to get up on a Saturday morning, but it's that day of the year again: the annual Sitecore hackathon day! 82 teams from 23 countries this year, and we -BeeGhentle- are one of them.

It will be my 5th participation, 6th if you count the one were I abandoned sick as well. I've had various results over those years: complete failure, submissions, almost-submissions and one victory. But each year we learned a lot and had fun. And that is what the hackathon is about.

Of course it is also about creating an open source application and getting ideas, examples (and maybe modules) out in the community. With a bit of time pressure as you get 24 hours straight to finish, and a little competition as well to make it even more exiting.

Learning

As mentioned, the hackathon is an excellent opportunity to learn. You could stick to your daily tasks and routine, but it's more fun if you try something new. As you only have limited time, one of the things to manage is how much time you can spend on learning and how much on actually building something to deliver.  

Getting ideas, managing your time, making the right decisions and handling pressure when the "start finishing" tweets start coming.. it all makes part of the learning process.

Normally every participant should have learned something new on a technical perspective during the hackathon. But I'm quite sure they also learned something on a more personal level - still business/work related though. And if you participated with a team that also works together the rest of the year (as I did), you probably also learned something about how your team functions.
from Priceless GIFs via Gfycat

Team "Bee Ghentle" 2020

This year I entered the competition with 2 of my (junior) colleagues. Our front-end beard-guy Sam had some experience with SXA but now was immersed into the wonders of SXA 9.3. Bearded handyman Alex had some encounters with marketing terminology, extending Forms (and meeting the limitations), Unicorns and his new best friend "Helix". I hope you learned a lot guys - you did a good job!

First step: an idea     

The topics this year were quite surprising and somewhat challenging:
  • "Sitecore Meetup Website": create a site for the Sitecore user groups to replace "Meetup"
  • "Sitecore Marketplace Website": create a new marketplace website
  • "Sitecore Hackathon Website": create a new website for the hackathon (or how to get people to create your website for free - just kidding, it would be a pleasure doing this)

Still a bit sleepy we decided to go for the first topic. We though the Commerce geniuses would build an awesome marketplace and the JSS experts would come up with a mind-blowing yearly-one-pager for the hackathon. We installed SXA and got started...

Our idea(s) might be a bit different than others, as we went a bit creative and didn't stick to what might be seen as the current situation.

We wanted to create a solution for all user groups. Why? Because we can. We could have gone for one site per user group, but we thought that one site to rule group them all would benefit people as user groups might start working together that way.

Next part of the idea was the fact that user group organizers know Sitecore. That is an assumption, but if they don't they should be fired as organizer. With a bit of security it should be possible to give organizers the ability to edit their own stuff in Sitecore directly. It's still a Content Management System - so let it handle content. This way we didn't have to worry about forms to create user group events. After all, it's a Sitecore hackathon, not a form-writing contest.

We also decided to skip a lot of tasks that we normally would do in a website because they seemed out-of-scope for a hackathon. So we didn't care about a 404 page, security headers, ... and focused on getting a mvp version that worked with some nice fancy tricks to show some Sitecore potential. Unfortunately we weren't able to do all of it - I must admit we do miss some functionality (e.g. unsubscribe - personalizing the subscribe button could help to do that trick) and we would have liked to get visitors recommended sessions based on your xDB profile.. but we ran out of time.

The build

We did manage to deliver a working version - or at least we hope it works once installed on another instance :)

The code and documentation can be found on Github (you can also find all other submissions there) - the video is also on Github.

We started with a faceted overview of the available user groups.

We should display the user group logo's on this page, but that is content - we tested with one image to make sure it works.

Selecting the user group brings the visitor to the group page with some general information and an overview of the events of that group - with the first upcoming event highlighted on top.


An event also has a (not-fully-styled) detail page with the sessions and speakers in more detail. We decided to share speakers across user groups as some of them tend to speak at quite a few. 

Visitors of user groups can register on the site - a (extranet) Sitecore user is created. We had some ideas with EXM and marketing automation here but those didn't make the cut for the deadline.
Once registered and logged in, you can subscribe to an event with a simple click (as we already you). Each event also displays the people that are coming.

As a finishing touch we created a component on the homepage that displays all events you subscribed for (this should be the upcoming events you subscribed, which is just a small change to the index query but we had no more time to test that so we decided to go as is). A Sitecore site needs something personalized, right?

The techy stuff

Creating the site in SXA was rather trivial for me. We did write a few extensions that helped us showing what we wanted with as little code and as much flexibility as possible.

On some parts we had to make decisions that were not perfect. To store the visitors of the user group, we took a shortcut writing them in the Sitecore database(s). Yes, this will not work on a scaled setup - we know. But we didn't manage to find the time setting up a custom database to store them.

The faceted overview was done with standard SXA components and scopes. To display the first upcoming event we used a custom datasource which we registered as item query to be used in a page list. Extra queries to display other lists of events could be easily created this way.

For the "Who's coming" feature and the "My next event" I created a Scriban extension. To get the users next event was a fairly easy query on the Sitecore index and we returned the event Item - this way we can display anything we want about the event in the Scriban template.

To display who is coming to the event was more challenging as I actually wanted to return the Sitecore users to the Scriban template so we could select any property we wanted there. That didn't seem to work so we had to settle with a function that returned the names. Still pretty neat though.

Next steps

I was still wondering about users in Scriban so I did what every Sitecore developer should do: I asked it on Sitecore StackExchange. And as expected, I did get an answer and it even seems possible... so that might be a next blog post ;)

Go home

We had some stress, we had some fun - we learned - we delivered something... maybe not everything we hoped for (did we aim too high?), but anyway, the hackathon 2020 is over. If all goes well we'll celebrate it at Sugcon in Budapest - whoever wins, they will have deserved it.  

I didn't check all the code on Github - I did watch the video playlist on Youtube (which does not include our video as it's not on Youtube). I've seen some (potentially) great entries..  but if I see the entries in the meetup topic I do notice we really took another angle. We'll leave it to the honorable judges to decide what they think about it...  I still think we had a good approach. 

But most importantly: we enjoyed it - and we're still alive!

Again next year?