Monday, October 19, 2020

SXA tokens in a Sitecore Treelist datasource

Treelist datasource

A Treelist is a field type in Sitecore that is commonly used to select one or more items from a tree view. It is easy to use for editors and has a rather special way to set the root (datasource) for the tree that is available to those editors. 

A few related field types are available ootb (e.g. TreelistEx) and the Sitecore community has provided rather a few blogpost already on how to customize the Treelist field - and especially the way we set the datasource. 

The basics of how to set a Treelist datasource using the "datasource=" parameters syntax are described very well in this post from Mark Ursino. Or in the official Sitecore archives where setting items available for selecting within the TreeList field was posted already in 2009! Out of the box you are able to select a parent path and determine which templates can be shown and/or selected.

As I mentioned Sitecore community people have been posting blogs on how to extend the datasource based on several different requirements. 

SXA tokens

Sitecore eXperience Accelerator has a pipeline called resolveTokens. This pipeline is used to resolve tokens that can be used in queries and is (amongst other) useful to make templates multisite-friendly. 

The fields on your templates should have sources when applicable - i.e. when another item is selected. This applies to link type fields, list type fields, the image field...  Setting those sources is easy in a single site environment, but when you want to use those templates in a multisite setup you usually want the source to be relative to the site, the site's data folder... 

This is possible with the SXA tokens. You can set sources like "query:$site/..." and those will be resolved relatively within your sites. In a previous post I already showed how to extend this by creating a token for the real media root folder (to be able to add additional -lower- folders after the token).

This is a really nice way of working, but... it only applies to sources that start with "query:".

SXA tokens in a Treelist datasource

I do have a multisite setup. With SXA. And some of my templates do have Treelist fields. You can use the query notation in the datasource and use tokens that way, but then you loose the flexibility of the other parameters - so I was looking for a solution to have both. I want parameters to define the possible templates and I also want to use tokens to define the parent. 

My research led me to two blogposts that inspired me. Diego Moretto wrote about dynamic datasources in Treelist fields but his solution was for non-SXA and with the SXA tokens we do already have so much flexibility. He based his solution on a post from Kamruz Jaman that explains a solution on how to embed queries within the datasource syntax.

While reading that (really good) post I thought I had my solution. By implementing this you actually do have a source that (can) start with "query:" so the resolveTokens pipeline will run and everything will work. I had a tweet ready to thank Kamruz, but then I stumbled upon a small issue: 

SXA cannot resolve the tokens outside an SXA site. This is very logical, but as this is the case in your standard values that resulted in errors when I used this in a feature template with standard values. I don't want errors and the solution was simple - just return a standard path instead of null when the result of the query is null.

As a recap, I'll add my version of the code here (but still - credits to Kamruz for the original):
public class TokenedTreelist : Sitecore.Shell.Applications.ContentEditor.TreeList
{
  public override string DataSource
  {
    get
    {
      var data = base.DataSource;
      if (!base.DataSource.StartsWith("query:", StringComparison.OrdinalIgnoreCase))
      {
        return data;
      }

      if (Sitecore.Context.ContentDatabase == null || ItemID == null)
      {
        return null;
      }

      var currentItem = Sitecore.Context.ContentDatabase.GetItem(ItemID);
      Item source = null;
      try
      {
        source = LookupSources.GetItems(currentItem, data).FirstOrDefault();
      }
      catch (Exception ex)
      {
        Log.Error("Treelist field failed to execute query.", ex, this);
      }

      return source == null ? "/sitecore/Content" : source.Paths.FullPath;
    }

    set => base.DataSource = value;
  }
}  
We are actually creating a new field here - fully using the TreeList as a base and overriding the Datasource property. A few minor changes were made to the code from Kamruz - but the most important one is the last line of the getter where we return "/sitecore/content" as default instead of null. 

Don't forget to register this field in the core database as this is a new field. Add an item at /sitecore/system/Field types/List Types and fill your assembly and class information.


Also add it to the field types list in the Sitecore config:
<sitecore>
  <fieldTypes>
    <fieldType patch:after="*[@name='Treelist']" name="Tokened Treelist" type="Sitecore.Data.Fields.MultilistField, Sitecore.Kernel" />
  </fieldTypes>
</sitecore>
You might want to add it to the index configuration as well - in our case that was not needed.

If we select "Tokened Treelist" as field type, we can now use all SXA tokens within our datasource. Such a datasource could look like:
datasource=query:$site/Data/Documents&allowmultipleselection=yes&includetemplatesfordisplay=Document,Document folder&includetemplatesforselection=Document

Conclusion

I don't think Kamruz realised in 2016 when he wrote that post that this would now enable me to use SXA tokens in a Treelist datasource. Sitecore community has proven this strength once more - so I had to share my findings as well to inform you about the extra options this can give us: multisite Treelists with SXA tokens! 

Tuesday, October 13, 2020

Cloning a SXA site (in another folder)

Cloning a Sitecore SXA site

Part 7 already in my series of blog posts related to a multisite project where we wanted to make it as easy as possible to create a new site - with the premise that all the sites can share almost all settings. As you might have read in the previous posts we are really doing this - and keeping it all flexible. It's not because we share (all) things, we cannot configure or overrule some parts if needed.

Anyway - we do have a setup with a shared site and a base "master" site within a tenant (as recommended by SXA). As I did not want these sites to be amongst all the other created sites, I placed those two in a Sites Folder within the tenant. We have (at least) one other Sites Folder where we want to place the actual sites - and we might even want to split those sites over different folders.

We use SXA site cloning to create a new site. This is a very easy and rapid process. The "Clone Site" Powershell (SPE) script that is used can be found by a right click on a site item in the Scripts section. It will ask the name of the new destination site and copy everything from the master site. Note that:
  • it will also copy media items within the sites media folder - really useful if you have a standard folder setup there
  • it will not copy any forms - a new empty folder is created though within Forms
  • it will change all links towards the newly created items

The script works fine and it is a very fast way to create a site. But.. it will create a site next to the master site. Of course, we can move all items after the creation - but it would be faster to just ask a preferred location and created the site there. So that's what I did...

The solution provided here is only tested with one level of site folders - it probably could work with a tree of those folders with a few more adjustments though. 

The Powershell scripts to clone a site

Show-CloneSiteDialog

First script to take care of is the dialog script. We want the user that clones a site to be able to choose the site folder where the destination site should be created. I created a copy of the ootb script "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Show-CloneSiteDialog" and adjusted that one (you could also opt to change the original one). The changes to the original script are not that big so I will not put the entire script here to keep focus.  

In the beginning of the script process the dialogParameters are defined. We add one more parameter there (siteLocation):
$dialogParameters.Add(@{ Name = "siteLocation"; 
  Title = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::Path); 
  Editor = "droplist"; Source="query:$($SourceSite.Parent.Paths.Path)/ancestor-or-self::*[@@templatename='Tenant']/*[@@templatename='Site Folder']"; 
  Tab = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::CloneSiteDialogGeneralTab); Mandatory=$true })  | Out-Null  
I am using a droplist here with a datasource query that finds my tenant and takes the children of type Site Folder. (note we are only supporting one level here - this customization would need to be changed to support a tree).
To keep it simple I used an existing text as label. The selection is marked as mandatory to make sure a choice is made.

The next (and final) change to this script is all the way at the bottom where the results are captured:
@{
    siteName              = $siteName
    siteDefinitionmapping = $mapping
    parent                = $siteLocation
}
We add a "parent" here that will contain the chosen location of the new site.

Clone-Site

Next script to adjust is the main one: "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Content Editor/Context Menu/Clone Site". This is the script that gets called from the context menu and we need to adjust it so we can use the parent from our dialog:
$ctx = Get-Item .
    
Import-Function Copy-Site
Import-Function Show-CloneSiteInParentDialog
    
$dialogResult = Show-CloneSiteInParentDialog $ctx
$cloneSiteName = $dialogResult.siteName
$mapping = $dialogResult.siteDefinitionmapping

#custom    
 $parent = $dialogResult.parent
 if (!$parent) {
     $parent = $ctx.Parent
  }
#endcustom

$destinationSite = Copy-Site $ctx $parent $cloneSiteName $mapping
$destinationSiteID = $destinationSite.ID.ToString()
$host.PrivateData.CloseMessages.Add("item:load(id=$destinationSiteID)")

Run-SiteManager
I am just showing the main part here (within the "try") to keep it clear. So we changed the call to Show-CloneSite to the one we created, we fetch the parent (with a fallback scenario although that is probably not needed) and pass it to the Copy-Site function.

And that's it. Well, that's what we thought so we tested this. The site was indeed created in the correct folder. So was the forms folder. But the media folder was still next to our master. 

Copy-Site

Let's take a look at the "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Copy-Site" script. The site creation was fine with our new parent. We noticed that creating the Forms folder was done as it would be for a new site and that script uses the paths to determine where to create a new (empty) folder - this works fine in our version as well.

So we need to take a look at the copy process for the media folder. Somewhere in the middle of the Copy-Site script you'll find the media part and let's customize that a bit:
if ($Site.SiteMediaLibrary) {
  $SiteMediaLibrary = $Site.Database.GetItem($Site.SiteMediaLibrary) | Wrap-Item

#custom
  if ($SiteMediaLibrary.Parent.Name -ne $destinationSite.parent.Name) {
    $SiteMediaRoot = $ctx.Database.GetItem($SiteMediaLibrary.Parent.Paths.Path.Replace($($SiteMediaLibrary.Parent.Name),$($destinationSite.parent.Name)))
    if (!$SiteMediaRoot) {
      $SiteMediaRoot = $ctx.Database.GetItem($ctx.SiteMediaLibrary).Parent
    }
  }
  else {
    $SiteMediaRoot = $SiteMediaLibrary.Parent
  }
#end custom

  $NewSiteMediaLibrary = Copy-RootAndFixReference $SiteMediaLibrary $SiteMediaRoot $destinationSite.Name

  Set-NewLinkReference $Site $destinationSite $SiteMediaLibrary.Paths.Path $NewSiteMediaLibrary.Paths.Path
  $destinationSite.SiteMediaLibrary = $NewSiteMediaLibrary.ID
}
Instead of copying the media tree to the parent of the master media tree, we use string replacement and the paths to determine the location we want. Note that we do need a folder structure corresponding to the Site Folders structure, otherwise this part will fail. We could expand the script to create the necessary folders if needed.


Conclusion

With that last change we managed it. Our new site cloning script will ask us where to create the destination site - of course within the same tenant and in our one-level Site Folder structure. Combined with all the stuff from my previous posts - and the ootb features - creating a site is becoming really fast.




Tuesday, October 6, 2020

Overriding partial designs from SXA shared site

Sitecore SXA shared partial designs

In a SXA multisite setup it is best/common practice to have a Shared site. This shared site can contain page and/or partial designs that can be used within the other sites.

Currently I am working on a multisite project where the aim is to make creating a site as easy as possible, knowing that all the sites will share as much as possible. In my previous posts I have been explaining how we shared various settings on top of the out of the box possibilities.

Partial designs

One of these ootb options are the partial designs. As our sites will share their main layout, header and footer (they can differ by theme) we do want to share the partial designs (and the page designs).

In our other sites -created from a master site- we do not have any partial nor page designs and I would like to keep it that way unless there is no other option.

This went fine, all ootb.. until we bumped into the logo in our header. This is actually a Rich Text component with a datasource -in the Shared site- and we do want to be able to override it when needed. Partial designs can be created with base designs and such but I really want to avoid having designs in the non-shared sites. 

Next issue was within the metadata partial design as we do have a 3rd party GDPR solution there that requires some javascript to be plugged in - which is a bit different for each domain. Our current implementation uses a html snippet so we needed a solution for that as well.  

Overriding a Rich Text datasource from a shared site

To solve our issue with the Rich Text component, I though I could easily create a new component -based on the original Rich Text- that works like the reusable Rich Text and additionally:
  1. checks the current site if a suited datasource was provided
  2. automatically uses the fallback (original datasource) from the shared site if no data was found in the current site
The code:
public class RichTextSharedController : VariantsController
{
  protected override object GetModel()
  {
      var model = base.GetModel() as VariantsRenderingModel;
      if (model?.DataSourceItem == null)
      {
          return model;
      }

      if (PageMode.IsExperienceEditor || PageMode.IsExperienceEditorEditing || !Context.Item.HasSharedSite())
      {
        return model; // skip this if we are in a shared site
      }

      var path = model.DataSourceItem.Paths.FullPath;
      var newPath = path.Replace(MultisiteContext.GetDataItem(model.DataSourceItem).Paths.ContentPath,
                MultisiteContext.DataItem.Paths.ContentPath);
      var contentItem = model.DataSourceItem.Database.GetItem(newPath);
      if (contentItem != null && contentItem.Versions.Count > 0)
      {
        //model.DataSourceItem = contentItem;
        model.Item = contentItem;
      }

      return model;
  }
}
We create a new controller that inherits from the VariantsController. We don't need a model or view as we will use the SXA versions for that. We override GetModel:
  1. Get the base model
  2. If there is no datasource, return the model as we have nothing more to do in this case
  3. If we are in edit mode, return the model as we want to show the original datasource in that mode
  4. If the current item does not have a shared site we also do not need to continue
  5. The actual fallback: we get the path of the datasource item (which is in the shared site) and want to move this path into the current site - we do this by replacing the paths of the data items from both sites
  6. We check if the "local" content item exists and adjust the model (note that we need to change the Item property - the DatasourceItem is optional)
To check if we have a shared site present, we used an extension method:
public static bool IsItemFromSite(this Item item, Item site)
{
  return item.Paths.FullPath.StartsWith(StringUtil.EnsurePostfix('/', site.Paths.FullPath), StringComparison.OrdinalIgnoreCase);
}

public static bool HasSharedSite(this Item item)
{
  var sites = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSites(item);
  return sites.Any() && !sites.Any(item.IsItemFromSite);
}
So now we have our controller. Next step is creating a new rendering in Sitecore. Easiest way to do this is to create a copy of the Rich Text (Reusable) rendering in your own folder and adjust the Controller field towards your implementation. As this is a new rendering, you probably also need to add it to the available renderings and placeholder settings. 

But that's it. We can now use this field in a partial design in the shared site - it will work just like a reusable rich text in all other sites with one big difference: if we create a Text datasource at the relative same location as the original datasource, this one will be used instead. We can now override datasources in Rich Text components in shared partial designs.  

Overriding a Html Snippet from a shared site

Next one is the html snippet. This is actually very similar to the Rich Text one. We created a controller based on the Sitecore.XA.Feature.GenericMetaRendering.Controllers.HtmlSnippetController and changed the GetModel just like in our Rich Text example. 

A few small differences though: for the snippet an extra property is present, called "Html". Changing the datasource is not sufficient - you also need to set the value for that property (again). In order to be able to do this, make sure the model is of type HtmlSnippetModel. Another difference is that our data is not in the data folder but in the settings folder so we have to use those when replacing the paths.
public class HtmlSnippetSharedController : HtmlSnippetController
{
  public HtmlSnippetSharedController(IHtmlSnippetRepository htmlSnippetRepository) : base(htmlSnippetRepository)
  {
  }

  protected override object GetModel()
  {
    var model = base.GetModel() as HtmlSnippetModel;
    if (model?.DataSourceItem == null)
    {
      return model;
    }

    if (PageMode.IsExperienceEditor || PageMode.IsExperienceEditorEditing || !Context.Item.HasSharedSite())
    {
      return model; // skip this if we are in a shared site
    }

    var path = model.DataSourceItem.Paths.FullPath;
    var newPath = path.Replace(MultisiteContext.GetSettingsItem(model.DataSourceItem).Paths.ContentPath,
                        MultisiteContext.SettingsItem.Paths.ContentPath);
    var contentItem = model.DataSourceItem.Database.GetItem(newPath);
    if (contentItem != null && contentItem.Versions.Count > 0)
    {
      model.DataSourceItem = contentItem;
      model.Html = model.DataSourceItem[Sitecore.XA.Feature.GenericMetaRendering.Templates.HtmlSnippet.Fields.Html];
    }

    return model;
  }
}  

To create your rendering, use a copy from the Html Snippet rendering (in the Generic Meta Rendering feature folder). Replace the Controller and in this case you also need to set the Rendering view path as that is not set here by default (it is for the Rich Text) - enter ~/Views/HtmlSnippet/HtmlSnippet.cshtml to use the ootb view.

Conclusion

We are now able to override datasources from (some) components in a partial design in a shared site, which makes our solution even more flexible. 

If you want those changes for the original components (instead of creating a new one), that is also possible. In that case you will need to create a new version of the repositories that create the model and put the same logic inside those repositories. Registering your version with the dependency injection will then do the trick.

As we are adding extra logic and item retrievals you might want to take extra care with caching on these components - but I guess that should always be the case...

This was a big step in our solution towards a very flexible site creation in minimal time. In a next post, we'll be diving into some Powershell.