When working with the Helix design patterns, a typical situation comes up where the project will include templates in the Feature or Foundation layer that contain TreeList, TreeListEx or DropTree fields that will need to enforce selections based on a template in that module. However, due to limitations from the Sitecore fields you can only enforce selections based on specific templates, not based on templates that are inherited. This is an issue when the modules exposes a base template, that will be inherited in a higher layer, but still needs to maintain the selection enforcement.
For example, lets say I am implementing a list module within the Feature layer and it includes the templates, List and List Item, where List Item templates will be selected on the List template, I then want to enforce that only List Item‘s are selected from within the List template. Now most likely, the Project Layer will inherit the List Item template in order to provide defaults or apply site specific workflow (any number of reasons really). And therein lies the problem, when you set the source of the field within the Feature layer, it is bound to a specific template(s) but it doesn’t take into account the inheritance that usually comes with Helix.
You have a few options here, you can say screw Helix standards and just set the Template ID in the Feature or Foundation layer OR you can extend the fields to support inheritance. I’m hoping you are here because you want a solution to the problem, if so, lets get coding!
The solution
In order to extend, in this case override, the default fields for TreeList, TreeList and DropTree, we have two options:
- Create new Sitecore Field Types for three fields to call custom classes that inherit from the code of those Field Types
- Use patch configuration to have Sitecore look for our classes first before defaulting to the out of the box configuration
I prefer the second approach in this instance as it is seamless in that all current instances of TreeList, TreeListEx and DropTree fields can use this new functionality while still supporting the old functionality and you do not need to create additional fields. However, I will note that because we are changing all instances of the fields, you will need to verify that Sitecore hasn’t changed the underlying fields when upgrading (the code below was written for 7.2 and reviewed with Sitecore 7.2, 8.2, 9, and 9.3 versions).
What we are going to do is extend both base implementations so that the fields can include key value pairs. With the below extensions, we will now be able to update the fields Source property with syntax below to enforce selections by the inheritance:
1 |
Datasource={Insert Sitecore Query or Item Path}&IncludeBaseTemplatesForSelection={Insert GUID} |
Extending the DropTree field
To extend the DropTree field, we will need to create two new classes, one that inherits from the Sitecore.Shell.Applications.ContentEditor.Tree and allows us to access the Source property to pull out new information and one class that inherits from Sitecore.Web.UI.HtmlControls.DataTreeview which allows us to access our new information and use it.
Add a new class called Tree (name needs to be exact):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
namespace Sitecore.Foundation.SitecoreExtensions.FieldTypes { using System; using System.Web.UI.WebControls; using Sitecore; using Sitecore.Diagnostics; using Sitecore.Globalization; using Sitecore.Web.UI.HtmlControls; using Sitecore.Web.UI.Sheer; /// <summary> /// Extends the <see cref="Sitecore.Shell.Applications.ContentEditor.Tree"/> by /// adding in support to filter the selectable templates in a Tree by the base templates /// Syntax for the Field Source: Datasource={Insert Sitecore Query or Item Path}&IncludeBaseTemplatesForSelection={Insert GUID} /// </summary> public class Tree : Sitecore.Shell.Applications.ContentEditor.Tree { #region Additional Fields for Filtering public string ExcludeTemplatesForDisplay { get { return this.GetViewStateString("ExcludeTemplatesForDisplay"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("ExcludeTemplatesForDisplay", value); } } public string ExcludeTemplatesForSelection { get { return this.GetViewStateString("ExcludeTemplatesForSelection"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("ExcludeTemplatesForSelection", value); } } public string IncludeTemplatesForDisplay { get { return this.GetViewStateString("IncludeTemplatesForDisplay"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("IncludeTemplatesForDisplay", value); } } public string IncludeTemplatesForSelection { get { return this.GetViewStateString("IncludeTemplatesForSelection"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("IncludeTemplatesForSelection", value); } } public string IncludeBaseTemplatesForSelection { get { return this.GetViewStateString("IncludeBaseTemplatesForSelection"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("IncludeBaseTemplatesForSelection", value); } } #endregion /// <summary> /// Overrides the source property to pull out the DataSource /// and filterable properties /// </summary> public new string Source { get { return base.Source; } set { if (value == null) { base.Source = value; } else { var contextItem = Client.ContentDatabase.GetItem(this.ItemID); var datasourceValue = StringUtil.ExtractParameter("DataSource", value).Trim(); if (datasourceValue.StartsWith("query:")) { this.ExcludeTemplatesForSelection = StringUtil.ExtractParameter("ExcludeTemplatesForSelection", value).Trim(); this.IncludeTemplatesForSelection = StringUtil.ExtractParameter("IncludeTemplatesForSelection", value).Trim(); this.IncludeTemplatesForDisplay = StringUtil.ExtractParameter("IncludeTemplatesForDisplay", value).Trim(); this.ExcludeTemplatesForDisplay = StringUtil.ExtractParameter("ExcludeTemplatesForDisplay", value).Trim(); this.IncludeBaseTemplatesForSelection = StringUtil.ExtractParameter("IncludeBaseTemplatesForSelection", value).Trim(); var queryItem = contextItem.Axes.SelectSingleItem(datasourceValue.Substring("query:".Length)); if (queryItem != null) { base.Source = queryItem.ID.ToString(); } } else if (value.StartsWith("query:")) { var queryItem = contextItem.Axes.SelectSingleItem(value.Substring("query:".Length)); if (queryItem != null) { base.Source = queryItem.ID.ToString(); } } else { base.Source = value; } } } } /// <summary> /// Overrides the <see cref="DropDown"/>, and changing the DataTreeView to use a FilteredTreeView /// </summary> protected override void DropDown() { if (!string.IsNullOrEmpty(this.Value)) { var dataContext = Sitecore.Context.ClientPage.FindSubControl(this.DataContext) as DataContext; Assert.IsNotNull(dataContext, typeof(DataContext), "Datacontext \"{0}\" not found.", (object)this.DataContext); dataContext.Folder = this.Value; } var hiddenHolder = UIUtil.GetHiddenHolder(this); DataTreeNode dataTreeNode = null; var scrollbox = new Scrollbox(); Sitecore.Context.ClientPage.AddControl(hiddenHolder, scrollbox); scrollbox.Width = 300; scrollbox.Height = 400; var dataTreeview = this.GetDataTreeview(); dataTreeview.Class = "scTreeview scPopupTree"; dataTreeview.DataContext = this.DataContext; dataTreeview.ID = this.ID + "_treeview"; dataTreeview.AllowDragging = false; if (this.AllowNone) { dataTreeNode = new DataTreeNode(); Sitecore.Context.ClientPage.AddControl(dataTreeview, dataTreeNode); dataTreeNode.ID = this.ID + "_none"; dataTreeNode.Header = Translate.Text("[none]"); dataTreeNode.Expandable = false; dataTreeNode.Expanded = false; dataTreeNode.Value = "none"; dataTreeNode.Icon = "Applications/16x16/forbidden.png"; } Sitecore.Context.ClientPage.AddControl(scrollbox, dataTreeview); dataTreeview.Width = new Unit(100.0, UnitType.Percentage); dataTreeview.Click = this.ID + ".Select"; dataTreeview.DataContext = this.DataContext; if (string.IsNullOrEmpty(this.Value) && dataTreeNode != null) { dataTreeview.ClearSelection(); dataTreeNode.Selected = true; } SheerResponse.ShowPopup(this.ID, "below-right", scrollbox); } protected virtual DataTreeview GetDataTreeview() { return new FilteredDataTreeView { ExcludeTemplatesForDisplay = Tree.GetValues(this.ExcludeTemplatesForDisplay), ExcludeTemplatesForSelection = Tree.GetValues(this.ExcludeTemplatesForSelection), IncludeTemplatesForDisplay = Tree.GetValues(this.IncludeTemplatesForDisplay), IncludeTemplatesForSelection = Tree.GetValues(this.IncludeTemplatesForSelection), IncludeBaseTemplatesForSelection = Tree.GetValues(this.IncludeBaseTemplatesForSelection) }; } private static string[] GetValues(string templates) { if (String.IsNullOrEmpty(templates)) { return null; } return templates.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); } } } |
Next we need to add the FilteredDataTreeView class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 |
namespace Sitecore.Foundation.SitecoreExtensions.FieldTypes { using System; using System.Linq; using Sitecore.Data.Items; using Sitecore.Foundation.SitecoreExtensions.Extensions; using Sitecore.Web.UI.HtmlControls; using Sitecore.Web.UI.Sheer; using Control = System.Web.UI.Control; /// <summary> /// Supporting class to allow additional paramters to filtered the tree /// </summary> internal class FilteredDataTreeView : DataTreeview { public string[] ExcludeTemplatesForDisplay { get { return this.GetViewStateProperty("ExcludeTemplatesForDisplay", null) as string[]; } set { this.SetViewStateProperty("ExcludeTemplatesForDisplay", value, null); } } public string[] ExcludeTemplatesForSelection { get { return this.GetViewStateProperty("ExcludeTemplatesForSelection", null) as string[]; } set { this.SetViewStateProperty("ExcludeTemplatesForSelection", value, null); } } public string[] IncludeTemplatesForDisplay { get { return this.GetViewStateProperty("IncludeTemplatesForDisplay", null) as string[]; } set { this.SetViewStateProperty("IncludeTemplatesForDisplay", value, null); } } public string[] IncludeTemplatesForSelection { get { return this.GetViewStateProperty("IncludeTemplatesForSelection", null) as string[]; } set { this.SetViewStateProperty("IncludeTemplatesForSelection", value, null); } } public string[] IncludeBaseTemplatesForSelection { get { return this.GetViewStateProperty("IncludeBaseTemplatesForSelections", null) as string[]; } set { this.SetViewStateProperty("IncludeBaseTemplatesForSelections", value, null); } } public override void HandleMessage(Message message) { if (message.Name == "event:keydown") { Sitecore.Context.ClientPage.ClientResponse.ClosePopups(false); message.CancelDispatch = true; } else { base.HandleMessage(message); } } protected override void Populate(DataContext dataContext, Control control, Item root, Item folder, string selectedIDs) { if (this.ExcludeTemplatesForDisplay == null && this.IncludeTemplatesForDisplay == null) { base.Populate(dataContext, control, root, folder, selectedIDs); } else { if (this.IncludeTemplatesForDisplay != null && !this.IncludeTemplatesForDisplay.Contains(root.TemplateName) || this.ExcludeTemplatesForDisplay != null && this.ExcludeTemplatesForDisplay.Contains(root.TemplateName)) { return; } base.Populate(dataContext, control, root, folder, selectedIDs); foreach (var dataTreeNode in control.Controls.OfType<DataTreeNode>().Where(p => p.Visible && p.Expandable)) { if (!String.IsNullOrEmpty(dataTreeNode.ItemID)) { var obj = dataContext.GetItem(dataTreeNode.ItemID); if (obj == null) { continue; } var flag = false; foreach (Item child in obj.Children) { if (this.IncludeTemplatesForDisplay != null && this.IncludeTemplatesForDisplay.Contains(child.TemplateName)) { flag = true; break; } if (this.ExcludeTemplatesForDisplay != null && !this.ExcludeTemplatesForDisplay.Contains(child.TemplateName)) { flag = true; break; } } if (!flag) { dataTreeNode.Expandable = false; dataTreeNode.Expanded = false; } } } } } protected override TreeNode GetTreeNode(Item item, Control parent) { var treeNode = base.GetTreeNode(item, parent); if (this.ExcludeTemplatesForSelection != null && this.ExcludeTemplatesForSelection.Contains(item.TemplateName)) { treeNode.Enabled = false; } if (this.ExcludeTemplatesForDisplay != null && this.ExcludeTemplatesForDisplay.Contains(item.TemplateName)) { treeNode.Visible = false; } if (this.IncludeTemplatesForSelection != null && !this.IncludeTemplatesForSelection.Contains(item.TemplateName)) { treeNode.Enabled = false; } if (this.IncludeTemplatesForDisplay != null && !this.IncludeTemplatesForDisplay.Contains(item.TemplateName)) { treeNode.Visible = false; } if (this.IncludeBaseTemplatesForSelection != null && !this.IncludeBaseTemplatesForSelection.Any(t => item.IsDerived(Sitecore.Data.ID.Parse(t)))) { treeNode.Enabled = false; } return treeNode; } protected override void NodeClicked(Message message, TreeNode node) { var dataTreeNode = node as DataTreeNode; if (dataTreeNode != null && !dataTreeNode.Enabled) { Sitecore.Context.ClientPage.ClientResponse.ClosePopups(false); message.CancelDispatch = true; } else { base.NodeClicked(message, node); } } } } |
Extending TreeList and TreeListEx
Extending the TreeList and TreeListEx fields is a lot more straightforward then the DropTree. We will need to add an additional class that extends the Sitecore.Shell.Applications.ContentEditor.TreeList and allows us to access the source property to pull out the additional filters. Note: Both Sitecore fields derive from the same class.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 |
namespace Sitecore.Foundation.SitecoreExtensions.FieldTypes { using System; using System.ComponentModel; using System.Linq; using Sitecore; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Foundation.SitecoreExtensions.Extensions; using Sitecore.Globalization; using Sitecore.Web.UI.HtmlControls; using Sitecore.Web.UI.Sheer; using Sitecore.Web.UI.WebControls; /// <summary> /// Extends the <see cref="Sitecore.Shell.Applications.ContentEditor.TreeList"/> by /// adding in support to filter the selectable templates in a Tree by the base templates /// Syntax for the Field Source: Datasource={Insert Sitecore Query or Item Path}&IncludeBaseTemplatesForSelection={Insert GUID} /// </summary> public class TreeList : Sitecore.Shell.Applications.ContentEditor.TreeList { #region Additional Fields for Filtering [Category("Data")] [Description("Comma separated list of item ids.")] public string IncludeBaseTemplatesForSelection { get { return this.GetViewStateString("IncludeBaseTemplatesForSelection"); } set { Assert.ArgumentNotNull(value, "value"); this.SetViewStateString("IncludeBaseTemplatesForSelection", value); } } #endregion /// <summary> /// Overrides the source property to pull out DataSource property /// </summary> public new string Source { get { return base.Source; } set { if (value == null) { base.Source = null; } else { var datasourceValue = StringUtil.ExtractParameter("DataSource", value).Trim(); if (datasourceValue.StartsWith("query:")) { base.Source = value.Replace(datasourceValue, this.ResolveQuery(datasourceValue)); } else if (value.StartsWith("query:")) { base.Source = this.ResolveQuery(value); } else { base.Source = value; } } } } /// <summary> /// Overrides the <see cref="OnLoad"/> method to set our custom properties /// </summary> /// <param name="args"></param> protected override void OnLoad(EventArgs args) { if (!Sitecore.Context.ClientPage.IsEvent) { this.SetProperties(); } base.OnLoad(args); } /// <summary> /// Overrides the <see cref="Add"/> method to execute our custom logic /// and determine if the selected item in the tree inherits from /// a configured base template /// </summary> protected new void Add() { if (this.Disabled) { return; } var viewStateString = this.GetViewStateString("ID"); var treeviewEx = this.FindControl(viewStateString + "_all") as TreeviewEx; Assert.IsNotNull(treeviewEx, typeof(DataTreeview)); var listbox = this.FindControl(viewStateString + "_selected") as Listbox; Assert.IsNotNull(listbox, typeof(Listbox)); var selectionItem = treeviewEx.GetSelectionItem(Language.Parse(this.ItemLanguage), Sitecore.Data.Version.Latest); if (selectionItem == null) { SheerResponse.Alert("Select an item in the Content Tree."); } else { if (!this.HasIncludeBaseTemplatesForSelection(selectionItem)) { return; } base.Add(); } } /// <summary> /// Resolves the Sitecore Query /// </summary> /// <param name="query">The Sitecore Query</param> /// <returns></returns> protected virtual string ResolveQuery(string query) { var contextItem = Sitecore.Context.ContentDatabase.Items[this.ItemID]; /** * This area can be an additional extension point to allow for 'tokens' * within the query, such as $site or $home. */ var obj = contextItem?.Axes.SelectSingleItem(query.Substring("query:".Length)); return obj != null ? obj.Paths.FullPath : String.Empty; } private void SetProperties() { this.IncludeBaseTemplatesForSelection = StringUtil.ExtractParameter("IncludeBaseTemplatesForSelection", this.Source).Trim(); } private bool HasIncludeBaseTemplatesForSelection(Item item) { if (String.IsNullOrEmpty(this.IncludeBaseTemplatesForSelection)) { return true; } var items = this.IncludeBaseTemplatesForSelection.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); if (!items.Any()) { return true; } return items.Any(id => item.IsDerived(new ID(id))); } } } |
Configuring our new Field Types
To override Sitecore’s default fields seamlessly, we need apply a patch configuration to the controlSources node that inserts a location to search before Sitecore’s fields are evaluated. When Sitecore is looking for the implementation of it’s fields, it looks for controlSources with a prefix of “content”, so if we include our own source with the same prefix before Sitecore’s we allow Sitecore to check our namespace to see if the type exists before using Sitecore’s.
1 2 3 4 5 6 7 8 |
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/"> <sitecore> <controlSources> <source patch:before="*[@namespace='Sitecore.Shell.Applications.ContentEditor']" mode="on" namespace="Sitecore.Foundation.SitecoreExtensions.FieldTypes" assembly="Sitecore.Foundation.SitecoreExtensions" prefix="content" /> </controlSources> </sitecore> </configuration> |
And that it, a gist is available here.