Cloning presentation details and associated datasources with Sitecore SXA

A recent investigation into a client requirement based on a Sitecore SXA project took me through a discovery of a value added functionality that can be re-purposed for other SXA projects and perhaps for the Sitecore community. The version of Sitecore SXA used at that time was version 1.7.1.

The primary functionality was to allow the client content authors to easily clone an existing page from the default language of English to other configured language versions. This will mean taking into consideration the following:

  1. All page property fields to be copied over.
  2. Layout definition comprised of building blocks of SXA components and its associated datasources (both local and reusable datasources), and any further linked references to be copied over. (Note: Some nested SXA components such as tabs does have associated presentation that is also factored into be copied over)

Some TLDR content below. If it bores you, skip straight to the Solution section.

To provide some context, the use case of this project involved a rollout of a total of 6 country sites all of which utilised a set of reusable SXA components alongside customised components. As part of the digital transformation initiative, the client marketing team needed to be trained to assemble new SXA pages quickly and make content updates prior to a full country site rollout. This initiative involved not just ensuring seamless operations transfer to the client but also to also allow the client can assemble pages quickly and efficiently across different languages.

During this time, the client discovered a pain point of having to manually replicate the layout and component definition from the default English language across to other language versions for example Thailand, Traditional Chinese (Hong Kong), Indonesian etc. For example, imagine the client having to copy a TabGroup composite component over the Chinese version and compose each TabItem by copying the content one by one into each tab. This involved several clicks to associate the added components with a new language version of its associated datasources, subsequently clicking save and waiting for the page to refresh and repeating the same steps, all in all creating a lengthy and time consuming tasks that is error prone to mistakes. This took almost half a day for the client to update one page across all other languages. The time consuming task was further compounded by the Experience Editor’s save and reload page time when it came to applying update changes to the page (I nearly pulled my hair before how long it took Experience Editor to reload a page with mid weight content and components).

The Solution

The answer to this was to customize toward the primary functionality mentioned above.

This can be easily be broken down to a few simple steps:

  1. Create a custom Experience Editor Button which I have called “Clone Layout Definition” which will sit under the Experience Accelerator Ribbon Tab.
  2. Create a backing JavaScript file to invoke your pipeline processor.
  3. Create a C# custom pipeline processor class to perform the required functionality on the context item page.

Note: When customising your processor for Experience Editor button functionality, you will need to ensure that you catch server side errors and log them accordingly as EE will just emit an ‘Error occured on the server’ error message if it encountered it during processing. In my example, I had not had the time to do it but you can add it on to your own. Brain Jocks explains well how to go about this.

Create a custom experience button

It was pretty easy to find some useful articles on how to go about adding a custom button to the Experience Editor. I will not mention the concepts to create one but you can refer to this article by Brain Jocks which provides a detailed explanation of customising an Experience Editor button in a Ribbon tab.

The snippet below illustrates the processor configuration.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/">
<sitecore>
<sitecore.experienceeditor.speak.requests>
<setting name="ExperienceEditor.XA.Foundation.Extensions.ExecuteLayoutDefinitionUpdate" value="XA.Foundation.Extensions.Commands.Requests.ExecuteLayoutDefinitionUpdate, XA.Foundation.Extensions.Commands" />
</sitecore.experienceeditor.speak.requests>
</sitecore>
</configuration>

 

Create the custom JavaScript file

This is the backing JavaScript file which hooks the Experience Editor’s client side event to the custom C# processor code.

define(["sitecore", "/-/speak/v1/ExperienceEditor/ExperienceEditor.js"], function (Sitecore, ExperienceEditor) {
Sitecore.Commands.CopyLayoutDefinitionsToLanguageVersions =
{
canExecute: function (context) {
return true;
},
execute: function (context) {

if (!confirm("Caution: You are about to overwrite the current page layout definition and its associated content from English over the to the current site language versions. Click ok to proceed.")) {
return;
}

ExperienceEditor.modifiedHandling(true, function() {
var requestContext = context.app.clone(context.currentContext);

context.app.disableButtonClickEvents();

ExperienceEditor.PipelinesUtil.generateRequestProcessor("ExperienceEditor.XA.Foundation.Extensions.ExecuteLayoutDefinitionUpdate", function (response) {
if (!response.responseValue.value) {
return;
}
}, requestContext).execute(context);


alert("Layout definition and associated contents copied.");

context.app.enableButtonClickEvents();
}); 
}
};
});

Create the custom C# processor pipeline class

Nothing beats understanding what is under the hood with Sitecore’s source code. My favourite tool is ILSpy (check it out here ) which enables me to peek down to Sitecore’s assemblies to actually see what it is doing. I decided that I will just make the idea simpler by cloning the existing page level field properties and together with its Presentation Layout definition. Presentation layout definition essentially means the sum of all the components (including nested components) on the Final Layout with its associated data sources.

To get this functionality working, it is important knowing some useful Sitecore APIs that are already defined in its assemblies, particularly ones which enable you to find the associated datasources and any reference links to any other Sitecore items.

Below is the code which details the entire workings to perform the cloning process as mentioned above.

namespace XA.Foundation.Extensions.Commands.Requests
{
public class ExecuteLayoutDefinitionUpdate : PipelineProcessorRequest<ItemContext>
{
private static readonly string DefaultLanguage = "en";


public override PipelineProcessorResponseValue ProcessRequest()
{
Item sourceItem = base.RequestContext.Item;
Assert.IsNotNull(sourceItem, "sourceItem");

if(sourceItem.Visualization.Layout == null)
{
return null;
}

if (!HasLocalDatasourceFolder(sourceItem))
{
return null;
}

Language.TryParse(DefaultLanguage, out Language defaultLang);

using (new LanguageSwitcher(defaultLang))
{
var otherLanguages = sourceItem.Languages.Where(l => l.Name != DefaultLanguage);
IList<Language> validLanguages = new List<Language>();
foreach (Language otherLanguage in otherLanguages)
{
var srcLangItem = sourceItem.Versions.GetLatestVersion(otherLanguage);

if (srcLangItem != null && srcLangItem.Versions.Count > 0)
{
validLanguages.Add(otherLanguage);
}
}

foreach (Language validLanguage in validLanguages)
{ 
Item dsItemTargetLang = sourceItem.Versions.GetLatestVersion(validLanguage);

if(dsItemTargetLang.Versions.Count > 0)
{
UpdatePagePropertyFields(sourceItem, dsItemTargetLang);
} 
}

var partialDesignItems = GetBasicPartialDesigns(base.RequestContext.Item);

var pdDsItems = GetPartialDesignDataSourceItems(partialDesignItems, base.RequestContext.DeviceItem, defaultLang);

foreach (Item pdDsItem in pdDsItems)
{
List<Field> sourceLangFields = new List<Field>();

GetFieldsInAllVersionsSelfAndDescendants(pdDsItem, sourceLangFields);

foreach (Language validLanguage in validLanguages)
{
Item dsItemTargetLang = pdDsItem.Versions.GetLatestVersion(validLanguage);

if (dsItemTargetLang.Versions.Count == 0)
{
AddItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}
else if (dsItemTargetLang.Versions.Count > 0)
{
UpdateItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}

UpdateCompositeLayoutwithAssociatedDataSourcesSelfAndDescendants(
pdDsItem,
base.RequestContext.DeviceItem,
defaultLang,
validLanguage); 
}
}

foreach (Language validLanguage in validLanguages)
{ 
foreach (Item partialDesignItem in partialDesignItems)
{
var targetPartialDesignItem = partialDesignItem.Versions.GetLatestVersion(validLanguage);
if (targetPartialDesignItem != null && targetPartialDesignItem.Versions.Count > 0)
{
UpdateFinalLayout(partialDesignItem, targetPartialDesignItem);
}
} 
}

List<Item> dsItems = GetPageLocalDataSourceItems(sourceItem, base.RequestContext.DeviceItem, defaultLang);

foreach (Item dsItem in dsItems)
{
List<Field> sourceLangFields = new List<Field>();

GetFieldsInAllVersionsSelfAndDescendants(dsItem, sourceLangFields);

foreach (Language validLanguage in validLanguages)
{
Item dsItemTargetLang = dsItem.Versions.GetLatestVersion(validLanguage);

if (dsItemTargetLang.Versions.Count == 0)
{
AddItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}
else if (dsItemTargetLang.Versions.Count > 0)
{
UpdateItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}

UpdateCompositeLayoutwithAssociatedDataSourcesSelfAndDescendants(
dsItem,
base.RequestContext.DeviceItem,
defaultLang,
validLanguage); 
}
}

foreach (Language validLanguage in validLanguages)
{
var targetItem = sourceItem.Versions.GetLatestVersion(validLanguage);
if (targetItem != null && targetItem.Versions.Count > 0)
{
UpdateFinalLayout(sourceItem, targetItem);
}
}

}

return new PipelineProcessorResponseValue
{

Value = "", 
};
}

private static void UpdatePagePropertyFields(Item sourceItem, Item targetItem)
{ 
IEnumerable<Field> sourceFields = GetFieldsInAllVersions(sourceItem);
UpdateTargetItemFields(targetItem, sourceFields);
}

private static List<Item> GetBasicPartialDesigns(Item sourceItem)
{
var pageDesignItem = sourceItem.TargetItem(Templates.Designable.Fields.PageDesign.ID);

if (pageDesignItem == null)
{
return new List<Item>();
}

var pdItems = ((MultilistField)pageDesignItem.Fields[Templates.PageDesign.Fields.PartialDesigns.ID]).GetItems();

var basicPdItems = pdItems.Where(pdItem => pdItem.Name.ToLower().Contains("basic")).ToList();

return basicPdItems;
}

private static List<Item> GetPartialDesignDataSourceItems(List<Item> basicPartialDesignItems, DeviceItem deviceItem, Language defaultLanguage)
{
List<Item> basicPdDsItems = new List<Item>();
; 
foreach (Item basicPdItem in basicPartialDesignItems)
{
var dsItems = GetDataSourceItems(basicPdItem, deviceItem, defaultLanguage);
basicPdDsItems.AddRange(dsItems);
}

return basicPdDsItems;
}

private static List<Item> GetPageLocalDataSourceItems(Item sourceItem, DeviceItem deviceItem, Language defaultLanguage)
{
List<Item> dsItems = GetDataSourceItems(sourceItem, deviceItem, defaultLanguage);

return dsItems;
}


private static List<Item> GetCompositeComponentDataSourceItems(Item sourceItem, DeviceItem deviceItem, Language defaultLanguage)
{
List<Item> dsItems = GetDataSourceItems(sourceItem, deviceItem, defaultLanguage);
return dsItems;
}

private static List<Item> GetDataSourceItems(Item sourceItem, DeviceItem deviceItem, Language defaultLanguage)
{
List<Item> dsItems = new List<Item>();

List<Item> list1 = GetLocalDataSourceItems(sourceItem);
List<Item> list2 = ItemUtility.GetItemsFromLayoutDefinedDatasources(sourceItem, deviceItem, defaultLanguage).ToList();
List<Item> list3 = ItemUtility.GetItemReferences(sourceItem).Where(r => (r.SourceFieldID == FieldIDs.LayoutField || r.SourceFieldID == FieldIDs.FinalLayoutField))
.Select(x => x.GetTargetItem()).Distinct(new SitecoreItemNameComparer()).Where(x => x != null && x.Visualization.Layout != null).ToList();

dsItems.AddRange(list1);
dsItems.AddRange(list2);
dsItems.AddRange(list3);

return dsItems;
}

private static List<Item> GetLocalDataSourceItems(Item sourceItem)
{
List<Item> dsItems = new List<Item>();

if(sourceItem.Children.Any(x => x.TemplateID == Templates.LocalDataSource_PageData.ID))
{
foreach (Item childDsItem in sourceItem.Children.FirstOrDefault(x => x.TemplateID == Templates.LocalDataSource_PageData.ID)?.GetChildren())
{
AddLocalDataSourceItemToList(childDsItem, dsItems);
}
}

return dsItems;
}

private static void AddLocalDataSourceItemToList(Item item, List<Item> dsItems)
{
dsItems.Add(item);

foreach (Item childDsItem in item.GetChildren())
{
AddLocalDataSourceItemToList(childDsItem, dsItems);
}
}

private static void UpdateCompositeLayoutwithAssociatedDataSourcesSelfAndDescendants(Item sourceDsItem, DeviceItem deviceItem, Language sourceLanguage, Language targetLanguage)
{
if(
((sourceDsItem.IsDerived(Templates.Composites.Datasource.Tabs.TabItem.ID) ||
sourceDsItem.IsDerived(Templates.Composites.Datasource.Accordion.AccordionItem.ID))
|| (sourceDsItem.IsDerived(Templates.Composites.Datasource.Tabs.ID) ||
sourceDsItem.IsDerived(Templates.Composites.Datasource.Accordion.ID)))
&& sourceDsItem.Visualization.Layout != null)
{
var targetDsItem = sourceDsItem.Versions.GetLatestVersion(targetLanguage);
if (targetDsItem != null)
{
if(targetDsItem.Versions.Count == 0)
{
var newTargetLangDsItem = targetDsItem.Versions.AddVersion();
UpdateFinalLayout(sourceDsItem, newTargetLangDsItem);
}
else if(targetDsItem.Versions.Count > 0)
{
UpdateFinalLayout(sourceDsItem, targetDsItem);
}
}

var layoutDsItems = GetCompositeComponentDataSourceItems(sourceDsItem, deviceItem, sourceLanguage);

foreach (Item layoutDsItem in layoutDsItems)
{
List<Field> sourceLangFields = new List<Field>();
GetFieldsInAllVersionsSelfAndDescendants(layoutDsItem, sourceLangFields);

Item dsItemTargetLang = layoutDsItem.Versions.GetLatestVersion(targetLanguage);

if (dsItemTargetLang.Versions.Count == 0)
{
AddItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}
else if (dsItemTargetLang.Versions.Count > 0)
{
UpdateItemVersionsSelfAndDescendants(dsItemTargetLang, sourceLangFields);
}
}
}

foreach (Item childSourceDsItem in sourceDsItem.GetChildren())
{
UpdateCompositeLayoutwithAssociatedDataSourcesSelfAndDescendants(childSourceDsItem, deviceItem, sourceLanguage, targetLanguage);
}
}

private static void AddItemVersionsSelfAndDescendants(Item targetItem, IEnumerable<Field> sourceLangFields)
{
var newTargetLang = targetItem.Versions.AddVersion();
UpdateTargetItemFields(newTargetLang, sourceLangFields);

foreach (Item childTargetItem in newTargetLang.Children)
{
AddItemVersionsSelfAndDescendants(childTargetItem, sourceLangFields);
}
}

private static void UpdateItemVersionsSelfAndDescendants(Item targetItem, IEnumerable<Field> sourceLangFields)
{
UpdateTargetItemFields(targetItem, sourceLangFields);

foreach (Item childItem in targetItem.Children)
{
UpdateItemVersionsSelfAndDescendants(childItem, sourceLangFields);
}
}

private static void UpdateTargetItemFields(Item targetItem, IEnumerable<Field> sourceLangFields)
{
var targetLangFields = GetFieldsInAllVersions(targetItem);

foreach (Field field in targetLangFields)
{
using (new EditContext(field.Item, false, false))
{
var sourceLangField = sourceLangFields.FirstOrDefault(x => x.Item.ID == field.Item.ID && x.Name == field.Name);
if (sourceLangField != null)
{
field.Value = sourceLangField.Value;
}
}
}
}

private static void GetFieldsInAllVersionsSelfAndDescendants(Item sourceItem, List<Field> allSourceFields)
{
GetFieldsInAllVersions(sourceItem, allSourceFields);

foreach (Item childItem in sourceItem.Children)
{
GetFieldsInAllVersionsSelfAndDescendants(childItem, allSourceFields);
}
}

private static IEnumerable<Field> GetFieldsInAllVersions(Item item)
{ 
item.Fields.ReadAll();
var fields = (IEnumerable<Field>)item.Fields.Where(f => !f.Name.StartsWith("__")).ToArray();
var itemVersions = item.Versions.GetVersions();
return fields.SelectMany(field => itemVersions, (field, itemVersion) => itemVersion.Fields[field.ID]); 
}

private static IEnumerable<Field> GetFieldsInAllVersions(Item item, List<Field> allFields)
{
item.Fields.ReadAll();
var fields = (IEnumerable<Field>)item.Fields.Where(f => !f.Name.StartsWith("__")).ToArray();
var itemVersions = item.Versions.GetVersions();
var fieldItemVersions = fields.SelectMany(field => itemVersions, (field, itemVersion) => itemVersion.Fields[field.ID]);
allFields.AddRange(fieldItemVersions);
return fieldItemVersions;
}

private static void UpdateFinalLayout(Item sourceItem, Item targetItem)
{ 
var sourceFinalLayoutField = new LayoutField(sourceItem.Fields[Sitecore.FieldIDs.FinalLayoutField]);
var targetFinalLayoutField = new LayoutField(targetItem.Fields[Sitecore.FieldIDs.FinalLayoutField]);

if (sourceFinalLayoutField == null)
{
Log.Warn($"Source Final Layout Field - Could not find layout on: {sourceItem.Name}", typeof(ExecuteLayoutDefinitionUpdate));
return;
}

if (targetFinalLayoutField == null)
{
Log.Warn($"Target Final Layout Field - Could not find layout on: {targetItem.Name}", typeof(ExecuteLayoutDefinitionUpdate));
}

if (string.IsNullOrEmpty(sourceFinalLayoutField.Value))
{
return;
}

var sourceFinalLayoutDefinition = LayoutDefinition.Parse(sourceFinalLayoutField.Value);

using (new EditContext(targetItem, false, false))
{
var targetFinalLayoutField2 = new LayoutField(targetItem.Fields[Sitecore.FieldIDs.FinalLayoutField]);
targetFinalLayoutField2.Value = sourceFinalLayoutDefinition.ToXml(); 
targetItem.Editing.AcceptChanges();
}
}

public static bool HasLocalDatasourceFolder(Item item)
{
if (item == null)
{
throw new ArgumentNullException(nameof(item));
}

return item.Children.Any(x => x.IsDerived(Templates.LocalDataSource_PageData.ID));
}
}

public class SitecoreItemNameComparer : IEqualityComparer<Item>
{
public bool Equals(Item x, Item y)
{
if(x != null && y != null)
return x.Name.Equals(y.Name);

return false;
}

public int GetHashCode(Item obj)
{
return 0;
}
}
}

The entire code will be made available soon in Github.

For the experience Sitecore developer, the concept of a Layout definition (both shared and final layout) should be familiar to you. If these concepts are not understood, I suggest referring back to the basics of Sitecore layout & presentation details and the differences between Shared and Final layout. Ankit Joshi provides a good explanation of the differences here: https://ankitjoshi2409.wordpress.com/2017/02/06/sitecore-shared-vs-final-layouts/

Please feel free to leave any feedback or comments.

I really hope this module will be of benefit to your SXA projects as well. This saved heaps of time from a content authoring perspective and more importantly benefits the customer first.

 

 

 

 

Advertisements

About adrianliew

Graduated with the Bachelor of Computer Science at the University of Melbourne
This entry was posted in .NET, SXA and tagged , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.