10

In a project, I have been working on before we had a solution in which when a content editor created a page of a certain type a Components folder was created and the renderings of that page would create their data sources in that folder.

For example: I have a news page type containing a news details rendering. When I create a News page called News the following structure would be created.

Content
|--News  
   |--Components  
      |--News Details  

Is this something that can be done using the presentation details of the standard values of the page type? Or do I really need to use branches here (not what I remember)

  • We can use Sitecore PowerShell extensions I believe. Check the link. This shows how to tap into Sitecore events like item created – phani Nov 28 '16 at 16:33
  • similar question - Create datasource when a rendering is added to the page – Harsh Baid Nov 28 '16 at 18:11
  • this is similar except the other question is about adding a rendering to an existing page. I am asking about creating a new page. – Gluip Nov 28 '16 at 18:53
  • I have written a rule for this. I will dig up the code and post it for you. – Zachary Kniebel Nov 28 '16 at 22:38
  • I added what I could to my answer and described the problem and how to get to the solution in detail. Apologies for not being able to include more, but the post is pretty long for a Stack post, as it is. The repo that I linked you to should be able to help you get a working example up and running. Combine that with my code-commenting and the guidance in my post and you should be able to implement this in a couple hours. Sorry it isn't simpler. There are A LOT of moving parts with this. – Zachary Kniebel Nov 28 '16 at 23:38
8

To answer your question: branch templates are the way to go, here. However, branch templates, alone will not give you all you need. I have actually written a rule to provide this "branch preset" functionality, and it is currently in use on a Production site.

Branch Presets

The term Branch presets refers to creating a branch template with the purpose of storing a reusable presentation configuration, complete with datasources that may or may not live within the branch itself.

What Sitecore Does and Does not Provide

Sitecore only provides support for using branch templates to create the item hierarchy. This means that you can use Sitecore OOTB tools alone to create your datasource items. However, Sitecore does not "relink" your datasources for you. What does this mean? Consider the following example:

Branches
|--News                # Has 'News Detail' rendering 
   |--Components  
      |--News Details  # Datasource of 'News Detail' rendering

In this example, if we have only OOTB functionality, when we add an instance of this branch template, we will get the following result:

Content
|--News                # 'News Detail' rendering datasource: branches/news/components/news details
   |--Components  
      |--News Details  # NOT datasource of 'News Detail' rendering

Notice that the problem here is that the News Detail rendering is still pointing at the News Details component in the Branch definition, not the instance of the branch. As such, we need to write some logic of our own to relink any datasources that live within the branch, itself.

Relinking Datasources

As before, in order for our branch presets to work, we need to write some logic to "relink" the datasources for any renderings on items in the branch that point to other items in the same branch. These datasources should be relinked to point at the items in the branch instance that correspond to the datasource items in the branch definition.

Triggering our Custom Logic

Triggering our custom logic is not as straightforward as you might think. Unfortunately, the item:added event has technically been deprecated and the item:created event is raised before create item's "master" (the reference to its branch template) has been set. We also don't want to pollute our item:saved and item:saving events, since these can have a big impact on performance. Fortunately, however, Sitecore's new item provider pipelines give us an excellent mechanism by which to trigger our logic: the <addFromTemplate> pipeline.

The <addFromTemplate> pipeline differs from the <uiAddFromTemplate> pipeline in that the <addFromTemplate> pipeline is called whenever an item is created, regardless of whether or not is created in the UI or where from in the UI.

Note that the <addFromTemplate> pipeline only runs once when adding an item from a branch template rather than running once for each item in the branch. As such, we will want to recurse over the sub-items of our "item" (branch instance root) being added and relink the datasources for each.

Executing our Logic

So we now know where, what and why, but we now need to figure out "how". Anyone who knows me at all will know what I'm about to suggest: the Rules Engine!

The Rules Engine is an awesome mechanism that provides not only a highly extensible and flexible solution, but also one that is maintainable and low-effort to implement and add to. Sales-pitch, done - here is what you want to do:

You are going to want to first write some conditions to check to see if the item was created from a branch template. Be sure not to add conditions to only call your logic in the content section of the tree. If you do, you will not be able to support branch templates that include instances of other branch templates.

The next thing that you will want to do is write an action to relink the branch datasources for item and its descendants. You can use string comparison on the relative paths from the datasource instance root to the relative paths from the datasource definition root to figure out which item linked to which. The only down-side is that this doesn't support items with duplicate names (which you really should try to avoid, anyway).

Enough Chat! Let's Code!

Full disclosure, all of the code, below, will work, but this is a pretty complex feature and I can't put the entire CSPROJ into this solution. As such, I am highlighting the important parts and providing you a link to a working repo that includes this rule. The repo also includes a TDS project that has all of the items necessary to run the solution. I most recently testing this in an 8.1u2 solution.

Configuration: Calling the Trigger

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>    
    <pipelines>
      <group name="itemProvider" groupName="itemProvider">
        <pipelines>
          <addFromTemplate>
            <processor mode="on" type="Delphic.Platform.Sc.Rules.Processors.AddFromTemplate.AddFromTemplateRulesProcessor, Delphic.Platform.Sc.Rules">
              <ruleFolderId>92F757EA-FB27-466C-937A-D44755BBBE7D</ruleFolderId>
            </processor>
          </addFromTemplate>
        </pipelines>
      </group>
    </pipelines>
  </sitecore>
</configuration>

Trigger: AddFromTemplate Processor

using System;
using Delphic.Platform.Sc.Rules.Contexts;
using Sitecore;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Diagnostics;
using Sitecore.Pipelines.ItemProvider.AddFromTemplate;
using Sitecore.Web.UI.Sheer;

namespace Delphic.Platform.Sc.Rules.Processors.AddFromTemplate
{
    public class AddFromTemplateRulesProcessor : AddFromTemplateProcessor
    {
        #region Public Properties

        /// <summary>
        /// Gets or sets the rule folder id.
        /// </summary>
        [CanBeNull]
        [UsedImplicitly]
        public string RuleFolderId { get; set; }

        #endregion

        #region Public Methods and Operators

        /// <summary>
        /// Processes the specified upload arguments.
        /// </summary>
        /// <param name="args">
        /// The arguments.
        /// </param>
        public override void Process([NotNull] AddFromTemplateArgs args)
        {
            // this is managed in configuration (runIfAborted=true would have to be set to override the value)
            if (args.Aborted)
            {
                return;
            }

            Assert.IsNotNull(args.FallbackProvider, "FallbackProvider is null");

            try
            {
                var item = args.FallbackProvider.AddFromTemplate(args.ItemName, args.TemplateId, args.Destination, args.NewId);
                if (item == null)
                {
                    return;
                }

                args.ProcessorItem = args.Result = item;
            }
            catch (Exception ex)
            {
                Log.Error("AddFromTemplateRulesProcessor failed. Removing partially created item if it exists.", ex, this);

                var item = args.Destination.Database.GetItem(args.NewId);
                item?.Delete();

                throw;
            }

            ID id;
            if (string.IsNullOrWhiteSpace(RuleFolderId)
                || !Settings.Rules.ItemEventHandlers.RulesSupported(args.Destination.Database)
                || !ID.TryParse(RuleFolderId, out id))
            {
                return;
            }

            var ruleContext = new PipelineArgsRuleContext<AddFromTemplateArgs>(args);

            RuleManager.RunRules(ruleContext, id);
        }

        #endregion
    }
}

Note that I route all of my rules through a RuleManager class to centralize logging/error-handling logic. (Credit to Matt Gramolini and Juozas "Jimbo" Baltika for their contributions to this structure and the code).

Action 1: Relink Datasources for Item:

using System.Runtime.InteropServices;
using Delphic.Platform.Sc.Rules.Actions.Base;
using Delphic.Platform.Sc.Rules.Utils;
using Sitecore;
using Sitecore.Rules;

namespace Delphic.Platform.Sc.Rules.Actions.BranchDatasources
{
    [UsedImplicitly]
    [Guid("4B439446-C6FD-4635-8282-147CA80A4CA5")]
    public sealed class RelinkItemBranchDatasources<TRuleContext> : RuleAction<TRuleContext>
        where TRuleContext : RuleContext
    {
        /// <summary>
        /// The apply rule.
        /// </summary>
        /// <param name="ruleContext">
        /// The rule context.
        /// </param>
        protected override void ApplyRule(TRuleContext ruleContext)
        {
            BranchDatasourceUtils.RelinkDatasourcesInBranchInstance(ruleContext.Item);
        }
    }
}

Action 2: Relink Datasources for Descendants:

using System.Runtime.InteropServices;
using Delphic.Platform.Sc.Rules.Actions.Base;
using Delphic.Platform.Sc.Rules.Utils;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Rules;

namespace Delphic.Platform.Sc.Rules.Actions.BranchDatasources
{
    [UsedImplicitly]
    [Guid("8E804F7A-77FF-4074-9BAC-7EEE0CFA89C6")]
    public sealed class RelinkDescendantsAndSelfBranchDatasources<TRuleContext> : RuleAction<TRuleContext>
        where TRuleContext : RuleContext
    {
        /// <summary>
        /// The apply rule.
        /// </summary>
        /// <param name="ruleContext">
        /// The rule context.
        /// </param>
        protected override void ApplyRule(TRuleContext ruleContext)
        {
            BranchDatasourceUtils.RelinkDatasourcesInBranchInstance(ruleContext.Item, true);
        }
    }
}

BranchDatasourceUtils:

using System;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Layouts;
using Sitecore.StringExtensions;

namespace Delphic.Platform.Sc.Rules.Utils
{
    public class BranchDatasourceUtils
    {
        /// <summary>
        /// Utility method for relinking branch datasources
        /// </summary>
        public static void RelinkDatasourcesInBranchInstance(Item item, bool descendants = false)
        {
            RelinkDatasourcesForItemInBranchInstance(item, item);

            if (!descendants)
            {
                return;
            }

            foreach (var descendant in item.Axes.GetDescendants())
            {
                RelinkDatasourcesForItemInBranchInstance(descendant, item);
            }
        }

        /// <summary>
        /// Utility method for relinking datasources for an item within a branch instance
        /// </summary>
        /// <remarks>
        /// Adapted from original code, written by Kam Figy: 
        /// https://github.com/kamsar/BranchPresets/blob/master/BranchPresets/AddFromBranchPreset.cs
        /// </remarks>
        public static void RelinkDatasourcesForItemInBranchInstance(Item item, Item instanceRoot)
        {
            Action<RenderingDefinition> relinkRenderingDatasource =
                rendering =>
                    RelinkRenderingDatasourceForItemInBranch(item, instanceRoot, rendering);

            LayoutUtils.ApplyActionToAllRenderings(item, relinkRenderingDatasource);
        }


        /// <summary>
        /// Utility method for relinking the datasource for the supplied rendering on an item in the branch instance
        /// </summary>
        /// <remarks>
        /// Adapted from original code, written by Kam Figy: 
        /// https://github.com/kamsar/BranchPresets/blob/master/BranchPresets/AddFromBranchPreset.cs
        /// </remarks>
        /// <param name="item">Item that contains the rendering</param>
        /// <param name="instanceRoot">Root item of the branch instance</param>
        /// <param name="rendering">Rendering to be relinked</param>
        public static void RelinkRenderingDatasourceForItemInBranch(Item item, Item instanceRoot, RenderingDefinition rendering)
        {
            var branchBasePath = item.Branch.InnerItem.Paths.FullPath;

            if (string.IsNullOrWhiteSpace(rendering.Datasource))
            {
                return;
            }

            var database = item.Database;

            // note: queries and multiple item datasources are not supported
            var renderingTargetItem = database.GetItem(rendering.Datasource);

            Assert.IsNotNull(
                renderingTargetItem,
                "Error while expanding branch template rendering datasources: data source {0} was not resolvable."
                    .FormatWith(rendering.Datasource));

            // if there was no valid target item OR the target item is not a child of the branch template we skip out
            if (renderingTargetItem == null ||
                !renderingTargetItem.Paths.FullPath.StartsWith(branchBasePath, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }

            // get the path relative to the branch item
            var relativeRenderingPath =
                renderingTargetItem.Paths.FullPath.Substring(branchBasePath.Length);

            // replace $name Sitecore tokens in path
            relativeRenderingPath = relativeRenderingPath
                .Replace("$name", instanceRoot.Name);

            var newTargetPath = instanceRoot.Parent.Paths.FullPath + relativeRenderingPath;
            var newTargetItem = database.GetItem(newTargetPath);

            // if the target item was a valid under branch item, but the same relative path does not exist under the branch instance
            // we set the datasource to something invalid to avoid any potential unintentional edits of a shared data source item
            rendering.Datasource = newTargetItem?.ID.ToString() ?? "INVALID_BRANCH_SUBITEM_ID";
        }
    }
}

LayoutUtils:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Layouts;
using Sitecore.SecurityModel;

namespace Delphic.Platform.Sc.Rules.Utils
{
    /// <summary>
    /// Layout Utilities class
    /// </summary>
    /// <remarks>
    /// Adapted from original code, written by Kam Figy: 
    /// https://github.com/kamsar/BranchPresets/blob/master/BranchPresets/AddFromBranchPreset.cs
    /// </remarks>
    public static class LayoutUtils
    {
        /// <summary>
        ///     Invokes Action on all Renderings on item
        /// </summary>
        public static void ApplyActionToAllRenderings(Item item, Action<RenderingDefinition> action)
        {
            ApplyActionToAllSharedRenderings(item, action);
            ApplyActionToAllFinalRenderings(item, action);
        }

        /// <summary>
        ///     Invokes Action on all Shared Renderings on item
        /// </summary>
        public static void ApplyActionToAllSharedRenderings(Item item, Action<RenderingDefinition> action)
        {
            ApplyActionToLayoutField(item, FieldIDs.LayoutField, action);
        }

        /// <summary>
        ///     Invokes Action on all Final Renderings on item
        /// </summary>
        public static void ApplyActionToAllFinalRenderings(Item item, Action<RenderingDefinition> action)
        {
            ApplyActionToLayoutField(item, FieldIDs.FinalLayoutField, action);
        }

        /// <summary>
        ///     Invokes Action on all Final Renderings on item
        /// </summary>
        private static void ApplyActionToLayoutField(Item item, ID fieldId, Action<RenderingDefinition> action)
        {
            var currentLayoutXml = LayoutField.GetFieldValue(item.Fields[fieldId]);
            if (string.IsNullOrEmpty(currentLayoutXml))
            {
                return;
            }

            var newXml = ApplyActionToLayoutXml(currentLayoutXml, action);
            if (newXml == null)
            {
                return;
            }

            using (new SecurityDisabler())
            {
                using (new EditContext(item))
                {
                    // NOTE: when dealing with layouts its important to get and set the field value with LayoutField.Get/SetFieldValue()
                    // if you fail to do this you will not process layout deltas correctly and may instead override all fields (breaking full inheritance), 
                    // or attempt to get the layout definition for a delta value, which will result in your wiping the layout details when they get saved.
                    LayoutField.SetFieldValue(item.Fields[fieldId], newXml);
                }
            }
        }

        private static string ApplyActionToLayoutXml(string xml, Action<RenderingDefinition> action)
        {
            var layout = LayoutDefinition.Parse(xml);

            // normalize the output in case of any minor XML differences (spaces, etc)
            xml = layout.ToXml(); 

            // loop over devices in the rendering
            for (var deviceIndex = layout.Devices.Count - 1; deviceIndex >= 0; deviceIndex--)
            {
                var device = layout.Devices[deviceIndex] as DeviceDefinition;
                if (device == null)
                {
                    continue;
                }

                // loop over renderings within the device
                for (var renderingIndex = device.Renderings.Count - 1; renderingIndex >= 0; renderingIndex--)
                {
                    var rendering = device.Renderings[renderingIndex] as RenderingDefinition;
                    if (rendering == null)
                    {
                        continue;
                    }

                    // run the action on the rendering
                    action(rendering);
                }
            }

            var layoutXml = layout.ToXml();

            // save a modified layout value if necessary
            return layoutXml != xml ? layoutXml : null;
        }
    }
}
  • Hi zachary. Thanks for this great idea and code! – Gluip Jan 16 '17 at 16:18
  • No problem. Feel free to reach out to me via PM in the Sitecore Community Slack (@zachary_kniebel) if you have any trouble implementing this. That goes for anyone else reading the post too! :) Good luck! – Zachary Kniebel Jan 16 '17 at 16:21
3

I think that the most flexible way to achieve what you want is to use a branch along with the Sitecore Smart Commands module. This module updates links when copying, duplicating, or creating from a template so that references to sub items correctly point to the new copies of those items after the the operation is completed.

For your case, as long as the presentation details are set up on the branch, the new page will have its datasource set correctly as well.

Note that if your solution is using relative datasource locations, that they may not work correctly when you edit your branch template item directly. What I have done in the past is to set up one page manually with the components folder and datasources. Then create a branch, but delete the $name item under it. Then copy the manually created page down under the branch item and rename it to $name and go through the fields and replace the values with tokens as appropriate.

3

This is a feature that's been kicking around the Sitecore community for a while now. To get the feature you're asking for, creating the /Components folder automatically as a subitem of the page you're working on, you need to modify the Layout field.

public class CreateContentFolder
{
    protected const string CONTENT_FOLDER_TEMPLATE_PARAM = "contentFolderTemplate";

    public void Process(GetRenderingDatasourceArgs args)
    {
        Assert.IsNotNull(args, "args");
        Sitecore.Data.Items.RenderingItem rendering = new Sitecore.Data.Items.RenderingItem(args.RenderingItem);
        UrlString urlString = new UrlString(rendering.Parameters);
        var contentFolder = urlString.Parameters[CONTENT_FOLDER_TEMPLATE_PARAM];
        if (string.IsNullOrEmpty(contentFolder))
        {
            // Set the content folder to the common folder template
            contentFolder = "{A87A00B1-E6DB-45AB-8B54-636FEC3B5523}";
        }
        if (!ID.IsID(contentFolder))
        {
            Log.Warn(
                string.Format("{0} for Rendering {1} contains improperly formatted ID: {2}", CONTENT_FOLDER_TEMPLATE_PARAM,
                              args.RenderingItem.Name, contentFolder), this);
            return;
        }

        string text = args.RenderingItem["Datasource Location"];
        if (!string.IsNullOrEmpty(text))
        {
            if (text.StartsWith("./") && !string.IsNullOrEmpty(args.ContextItemPath))
            {
                var itemPath = args.ContextItemPath + text.Remove(0, 1);
                var item = args.ContentDatabase.GetItem(itemPath);
                var contextItem = args.ContentDatabase.GetItem(args.ContextItemPath);
                if (item == null && contextItem != null)
                {
                    // split the path on '/' characters and create nested folders as needed.
                    string itemName = text.Remove(0, 2);
                    string[] itemNames = itemName.Split('/');
                    var currentItem = contextItem;
                    foreach (string name in itemNames)
                    {
                        if (string.IsNullOrEmpty(name))
                        {
                            // sanity check
                            continue;
                        }
                        // try to get the item at this level
                        item = args.ContentDatabase.GetItem(string.Format("{0}/{1}", currentItem.Paths.FullPath, name));
                        // if it doesn't exist create it.
                        if (item == null)
                        {
                            //if we create an item in the current site context, the WebEditRibbonForm will see an ItemSaved event and think it needs to reload the page
                            using (new SiteContextSwitcher(SiteContextFactory.GetSiteContext("system")))
                            {
                                item = currentItem.Add(name, new TemplateID(ID.Parse(contentFolder)));
                            }
                        }
                        // Either way set current item to the item for the next iteration
                        currentItem = item;
                    }
                }
            }
        }
    }
}

You'll need to add this into the GetRenderingDatasource pipeline like so,

<getRenderingDatasource>
    <processor type="Sitecore.SharedSource.PageEditor.CustomSitecore.Pipeline.GetRenderingDatasource.CreateContentFolder, Sitecore.SharedSource.PageEditor" 
        patch:before="processor[@type='Sitecore.Pipelines.GetRenderingDatasource.GetDatasourceLocation, Sitecore.Kernel']"/>
</getRenderingDatasource>

To use the feature, you need to designate the datasource path as a relative path. This code expects it to start with './'. So, ./Components/News Details, in your example.

This was built against and older version of Sitecore, 6.6 I believe, but you should be able to pull this code as a starting point to implement it against your solution.

  • 1
    It seems like you posted code for a GetRenderingDatasource pipeline processor. This doesn't 'fit' the Layout field class interface. Should I insert this as a processor? – Gluip Nov 28 '16 at 19:20
  • You're right! This is the correct class but the wrong override. Thanks! – Chris Sulham Nov 28 '16 at 19:30
  • You also need to multiple locations pipes with | but otherwise, as you say a pretty standard way of doing this. +1 – jammykam Nov 28 '16 at 19:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.