When you are working with Sitecore sites where the pages have a lot of inserted renderings you usually also end up with a lot of data sources and if they are all placed in a central location then you quickly loose overview of which data sources that goes with which pages. One way to solve this is to move the page specific data sources to a location under the individual pages. This makes everything tidy and it has the added benefit that when a page is deleted all the data sources are deleted along with it.
However, what happens when an editor decides to copy the page along with all the data source items because she wants to use it as a starting point for a new page? Well unfortunately, she ends up with a new page where all the renderings points to the original data source items and most of the time she will not understand why her changes to the copied data source items are not shown on the copied page.
So how can we solve this problem? Well if the data source references in the rendering details were relative to the current page instead of absolute then copying the page with the data sources would mean that the references on the copied page would automatically point to the copied data source items. This would be easy to solve if we could use a Sitecore query instead of an absolute path, but unfortunately the data source field on the rendering does not support Sitecore queries, which is a bit odd since the equivalent data source location field on the rendering definition item does. The data source field does contain a query option but this is using the content search and does not accept a Sitecore query. Therefore, we need to make a few adjustments to get this to work.
This only requires a few changes to implement. We need to replace the data source paths with relative paths when an item is saved and make sure that the paths are reconstructed when the data source paths are resolved. So let us look at replacing the data source paths with relative paths first.
To replace the data source paths we will need to implement an item saving event like the following.
using System; using Sitecore; using Sitecore.Data.Fields; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Events; namespace Datasources { public class InsertPageRelativeDatasource { public void OnItemSaving(object sender, EventArgs args) { if (Context.ContentDatabase == null || !Context.ContentDatabase.Name.ToLowerInvariant().Equals("master")) return; Assert.ArgumentNotNull(args, "args"); Item item = Event.ExtractParameter(args, 0) as Item; if (item == null) return; ReplaceDatasourceIdReferences(item); } private void ReplaceDatasourceIdReferences(Item item) { try { Field layoutField = item.Fields[FieldIDs.LayoutField]; Field finalLayoutField = item.Fields[FieldIDs.FinalLayoutField]; if (string.IsNullOrEmpty(layoutField.Value) && string.IsNullOrEmpty(finalLayoutField.Value)) return; if (layoutField.ContainsStandardValue && finalLayoutField.ContainsStandardValue) return; Item pageDataFolderItem = GetPageDataFolder(item); if (pageDataFolderItem == null) return; bool itemChanged = false; item.Editing.BeginEdit(); foreach (Item datasourceItem in pageDataFolderItem.GetChildren()) { if (!layoutField.ContainsStandardValue && layoutField.Value.Contains("s:ds=\"" + datasourceItem.ID)) { ReplaceDatasourceIdWithPageRelativePath(layoutField, datasourceItem, item); itemChanged = true; } if (!finalLayoutField.ContainsStandardValue && finalLayoutField.Value.Contains("s:ds=\"" + datasourceItem.ID)) { ReplaceDatasourceIdWithPageRelativePath(finalLayoutField, datasourceItem, item); itemChanged = true; } } if (!itemChanged) { item.Editing.CancelEdit(); return; } item.Editing.EndEdit(false, false); } catch (Exception ex) { Log.Error($"An error occurred in {GetType().FullName}.ReplaceDatasourceIdReferences()", ex, this); } } private static Item GetPageDataFolder(Item item) { return item.Axes.SelectSingleItem("./*[@@templatekey='datafolder']"); } private static void ReplaceDatasourceIdWithPageRelativePath(Field field, Item datasourceItem, Item pageItem) { field.SetValue(field.Value.Replace("s:ds=\"" + datasourceItem.ID, "s:ds=\"page:" + datasourceItem.Paths.FullPath.Replace(pageItem.Paths.FullPath, string.Empty)), false); } } }
To keep the code simple and for the sake of the example I have chosen to simplify the event handler slightly. It will only look for a data folder item located underneath the page item and traverse over the child elements of this data folder to replace any references to the child items in the layout or final layout fields of the page item. This means that for this to work all your data source items have to be organized like this. If they are not stored in a single folder another options is to use the LayoutField class to access your layout and final layout fields and traverse over the rendering definitions in order to update their data source value. John West has written a blog post on how to use the LayoutField to update layout information https://community.sitecore.net/technical_blogs/b/sitecorejohn_blog/posts/programmatically-update-layout-details-with-the-sitecore-asp-net-cms.
With the event handler done we now have to register it with the item saving event in Sitecore.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <events> <event name="item:saving"> <handler type="WDH.Feature.Datasources.EventHandlers.InsertPageRelativeDatasource, WDH.Feature.Datasources" method="OnItemSaving"/> </event> </events> </sitecore> </configuration>
Next, we have to work on a way to restore the data source paths and here we can use the resolveRenderingDatasource pipeline so let us implement a processor.
using System; using Sitecore; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Pipelines.ResolveRenderingDatasource; namespace Datasources { public class ResolvePageRelativeDatasourcePath { public void Process(ResolveRenderingDatasourceArgs args) { try { if (string.IsNullOrEmpty(args?.Datasource) || !IsPageRelativePath(args.Datasource)) return; Item contextItem = args.CustomData["contextItem"] as Item ?? Context.Item; if (contextItem == null) { args.Datasource = string.Empty; return; } args.Datasource = ResolveDatasourcePath(args.Datasource, contextItem.Paths.FullPath); } catch (Exception ex) { Log.Error($"An error occurred in {GetType().FullName}.Process()", ex, this); } } private string ResolveDatasourcePath(string datasourcePath, string contextItemPath) { if (String.IsNullOrEmpty(datasourcePath)) return string.Empty; if (IsPageRelativePath(datasourcePath)) return contextItemPath + RemovePageRelativePrefix(datasourcePath); return datasourcePath; } private bool IsPageRelativePath(string path) { return path.StartsWith(PageRelativePrefix, StringComparison.InvariantCulture); } private string RemovePageRelativePrefix(string path) { return path.Remove(0, PageRelativePrefix.Length); } private const string PageRelativePrefix = "page:"; } }
With the processor completed, we have to insert it into the resolveRenderingDatasource pipeline as follows.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <resolveRenderingDatasource> <processor type="Datasources.ResolvePageRelativeDatasourcePath, Datasources" /> </resolveRenderingDatasource> </pipelines> </sitecore> </configuration>
With the event handler and the processor in place, we now have the two operations that are needed to make the relative datasource paths work. When an item is saved any datasource references to items that are placed in a datafolder under the item are updated to be relative to the current item and when the datasources are resolved the full path is restored.