Home » Sitecore (Page 14)
Category Archives: Sitecore
Kick-start Glass.Sitecore.Mapper in a Sitecore Initialize Pipeline
In my previous post, I used Glass.Sitecore.Mapper to grab content out of Sitecore for use in expanding tokens set in Standard Values fields.
While writing the code for that article, I recalled Alex Shyba asking whether it were possible to move Glass initialization code out of the Global.asax and into an initialize pipeline — Mike Edwards, the developer of Glass, illustrates how one initializes Glass in the Global.asax on github.
I do remember Mike saying it were possible, although I am uncertain whether anyone has done this.
As a follow up to Alex’s tweet, I’ve decided to do just that — create an initialize pipeline that will load up Glass models. I’ve also moved the model namespaces and assembly definitions into a patch config file along with defining the initialize pipeline.
Here is the pipeline I’ve written to do this:
using System.Collections.Generic;
using System.Linq;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Pipelines;
using Glass.Sitecore.Mapper.Configuration.Attributes;
namespace Sitecore.Sandbox.Pipelines.Loader
{
public class InitializeGlassMapper
{
public void Process(PipelineArgs args)
{
CreateContextIfApplicable(GetModelTypes());
}
private static void CreateContextIfApplicable(IEnumerable<string> modelTypes)
{
if (CanCreateContext(modelTypes))
{
CreateContext(CreateNewAttributeConfigurationLoader(modelTypes));
}
}
private static bool CanCreateContext(IEnumerable<string> modelTypes)
{
return modelTypes != null && modelTypes.Count() > 0;
}
private static AttributeConfigurationLoader CreateNewAttributeConfigurationLoader(IEnumerable<string> modelTypes)
{
Assert.ArgumentNotNull(modelTypes, "modelTypes");
Assert.ArgumentCondition(modelTypes.Count() > 0, "modelTypes", "modelTypes collection must contain at least one string!");
return new AttributeConfigurationLoader(modelTypes.ToArray());
}
private static void CreateContext(AttributeConfigurationLoader loader)
{
Assert.ArgumentNotNull(loader, "loader");
Glass.Sitecore.Mapper.Context context = new Glass.Sitecore.Mapper.Context(loader);
}
private static IEnumerable<string> GetModelTypes()
{
return Factory.GetStringSet("glassMapperModels/type");
}
}
}
I’ve defined my new initialize pipeline in a patch config, coupled with Glass model namespace/assembly pairs:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<pipelines>
<initialize>
<processor type="Sitecore.Sandbox.Pipelines.Loader.InitializeGlassMapper, Sitecore.Sandbox" />
</initialize>
</pipelines>
<glassMapperModels>
<type>Sitecore.Sandbox.Model, Sitecore.Sandbox</type>
</glassMapperModels>
</sitecore>
</configuration>
On the testing front, I validated what I developed for my previous post still works — it still works like a charm! 🙂
Content Manage Custom Standard Values Tokens in the Sitecore Client
A few days back, John West — Chief Technology Officer at Sitecore USA — blogged about adding custom tokens in a subclass of Sitecore.Data.MasterVariablesReplacer.
One thing that surprised me was how his solution did not use NVelocity, albeit I discovered why: the class Sitecore.Data.MasterVariablesReplacer does not use it, and as John states in this tweet, using NVelocity in his solution would have been overkill — only a finite number of tokens are defined, so why do this?
This kindled an idea — what if we could define such tokens in the Sitecore Client? How would one go about doing that?
This post shows how I did just that, and used NVelocity via a utility class I had built for my article discussing NVelocity
I first created two templates: one that defines the Standard Values variable token — I named this Variable — and the template for a parent Master Variables folder item — this has no fields on it, so I’ve omitted its screenshot:
I then defined some Glass.Sitecore.Mapper Models for my Variable and Master Variables templates — if you’re not familiar with Glass, or are but aren’t using it, I strongly recommend you go to http://www.glass.lu/ and check it out!
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using Sitecore.Diagnostics;
using Sitecore.Reflection;
using Glass.Sitecore.Mapper.Configuration.Attributes;
namespace Sitecore.Sandbox.Model
{
[SitecoreClass(TemplateId="{E14F91E4-7AF6-42EC-A9A8-E71E47598BA1}")]
public class Variable
{
[SitecoreField(FieldId="{34DCAC45-E5B2-436A-8A09-89F1FB785F60}")]
public virtual string Token { get; set; }
[SitecoreField(FieldId = "{CD749583-B111-4E69-90F2-1772B5C96146}")]
public virtual string Type { get; set; }
[SitecoreField(FieldId = "{E3AB8CED-1602-4A7F-AC9B-B9031FCA2290}")]
public virtual string PropertyName { get; set; }
public object _TypeInstance;
public object TypeInstance
{
get
{
bool shouldCreateNewInstance = !string.IsNullOrEmpty(Type)
&& !string.IsNullOrEmpty(PropertyName)
&& _TypeInstance == null;
if (shouldCreateNewInstance)
{
_TypeInstance = CreateObject(Type, PropertyName);
}
return _TypeInstance;
}
}
private object CreateObject(string type, string propertyName)
{
try
{
return GetStaticPropertyValue(type, propertyName);
}
catch (Exception ex)
{
Log.Error(this.ToString(), ex, this);
}
return null;
}
private object GetStaticPropertyValue(string type, string propertyName)
{
PropertyInfo propertyInfo = GetPropertyInfo(type, propertyName);
return GetStaticPropertyValue(propertyInfo);
}
private PropertyInfo GetPropertyInfo(string type, string propertyName)
{
return GetType(type).GetProperty(propertyName);
}
private object GetStaticPropertyValue(PropertyInfo propertyInfo)
{
Assert.ArgumentNotNull(propertyInfo, "propertyInfo");
return propertyInfo.GetValue(null, null);
}
private Type GetType(string type)
{
return ReflectionUtil.GetTypeInfo(type);
}
}
}
In my Variable model class, I added some logic that employs reflection to convert the defined type into an object we can use. This logic at the moment will only work with static properties, although could be extended for instance properties, and even methods on classes.
Here is the model for the Master Variables folder:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Glass.Sitecore.Mapper.Configuration.Attributes;
namespace Sitecore.Sandbox.Model
{
[SitecoreClass(TemplateId = "{855A1D19-5475-43CB-B3E8-A4960A81FEFF}")]
public class MasterVariables
{
[SitecoreChildren(IsLazy=true)]
public virtual IEnumerable<Variable> Variables { get; set; }
}
}
I then hooked up my Glass models in my Global.asax:
<%@Application Language='C#' Inherits="Sitecore.Web.Application" %>
<%@ Import Namespace="Glass.Sitecore.Mapper.Configuration.Attributes" %>
<script runat="server">
protected void Application_Start(object sender, EventArgs e)
{
AttributeConfigurationLoader loader = new AttributeConfigurationLoader
(
new string[] { "Sitecore.Sandbox.Model, Sitecore.Sandbox" }
);
Glass.Sitecore.Mapper.Context context = new Glass.Sitecore.Mapper.Context(loader);
}
public void Application_End()
{
}
public void Application_Error(object sender, EventArgs args)
{
}
</script>
Since my solution only works with static properties, I defined a wrapper class that accesses properties from Sitecore.Context for illustration:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sitecore.Sandbox.Data
{
public class SitecoreContextProperties
{
public static string DomainName
{
get
{
return Sitecore.Context.Domain.Name;
}
}
public static string Username
{
get
{
return Sitecore.Context.User.Name;
}
}
public static string DatabaseName
{
get
{
return Sitecore.Context.Database.Name;
}
}
public static string CultureName
{
get
{
return Sitecore.Context.Culture.Name;
}
}
public static string LanguageName
{
get
{
return Sitecore.Context.Language.Name;
}
}
}
}
I then defined my subclass of Sitecore.Data.MasterVariablesReplacer. I let the “out of the box” tokens be expanded by the Sitecore.Data.MasterVariablesReplacer base class, and expand those defined in the Sitecore Client by using my token replacement utility class and content acquired from the master database via my Glass models:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Collections;
using Sitecore.Data;
using Sitecore.Diagnostics;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Text;
using Glass.Sitecore.Mapper;
using Sitecore.Sandbox.Model;
using Sitecore.Sandbox.Utilities.StringUtilities.Base;
using Sitecore.Sandbox.Utilities.StringUtilities;
using Sitecore.Sandbox.Utilities.StringUtilities.DTO;
namespace Sitecore.Sandbox.Data
{
public class ContentManagedMasterVariablesReplacer : MasterVariablesReplacer
{
private const string MasterVariablesDatabase = "master";
public override string Replace(string text, Item targetItem)
{
return CreateNewTokenator().ReplaceTokens(base.Replace(text, targetItem));
}
public override void ReplaceField(Item item, Field field)
{
base.ReplaceField(item, field);
field.Value = CreateNewTokenator().ReplaceTokens(field.Value);
}
private static ITokenator CreateNewTokenator()
{
return Utilities.StringUtilities.Tokenator.CreateNewTokenator(CreateTokenKeyValues());
}
private static IEnumerable<TokenKeyValue> CreateTokenKeyValues()
{
IEnumerable<Variable> variables = GetVariables();
IList<TokenKeyValue> tokenKeyValues = new List<TokenKeyValue>();
foreach (Variable variable in variables)
{
AddVariable(tokenKeyValues, variable);
}
return tokenKeyValues;
}
private static void AddVariable(IList<TokenKeyValue> tokenKeyValues, Variable variable)
{
Assert.ArgumentNotNull(variable, "variable");
bool canAddVariable = !string.IsNullOrEmpty(variable.Token) && variable.TypeInstance != null;
if (canAddVariable)
{
tokenKeyValues.Add(new TokenKeyValue(variable.Token, variable.TypeInstance));
}
}
private static IEnumerable<Variable> GetVariables()
{
MasterVariables masterVariables = GetMasterVariables();
if (masterVariables != null)
{
return masterVariables.Variables;
}
return new List<Variable>();
}
private static MasterVariables GetMasterVariables()
{
const string masterVariablesPath = "/sitecore/content/Settings/Master Variables"; // hardcoded here for illustration -- please don't hardcode paths!
ISitecoreService sitecoreService = GetSitecoreService();
return sitecoreService.GetItem<MasterVariables>(masterVariablesPath);
}
private static ISitecoreService GetSitecoreService()
{
return new SitecoreService(MasterVariablesDatabase);
}
}
}
At first, I tried to lazy instantiate my Tokenator instance in a property, but discovered that this class is only instantiated once — that would prevent newly added tokens from ever making their way into the ContentManagedMasterVariablesReplacer instance. This is why I call CreateNewTokenator() in each place where a Tokenator instance is needed.
I then wedged in my ContentManagedMasterVariablesReplacer class using a patch config file:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<settings>
<setting name="MasterVariablesReplacer">
<patch:attribute name="value">Sitecore.Sandbox.Data.ContentManagedMasterVariablesReplacer,Sitecore.Sandbox</patch:attribute>
</setting>
</settings>
</sitecore>
</configuration>
Now, let’s see if this works.
We first need some variables:
Next, we’ll need an item for testing. Let’s create a template with fields that will be populated using my Sitecore.Sandbox.Data.ContentManagedMasterVariablesReplacer class:
Under /sitecore/content/Home, I created a new item based on this test template.
We can see that hardcoded tokens were populated from the base Sitecore.Data.MasterVariablesReplacer class:
The content managed tokens were populated from the Sitecore.Sandbox.Data.ContentManagedMasterVariablesReplacer class:
That’s all there is to it.
Please keep in mind there could be potential performance issues with the above, especially when there are lots of Variable items — the code always creates an instance of the Tokenator in the ContentManagedMasterVariablesReplacer instance, which is pulling content from Sitecore each time, and we’re using reflection for each Variable. It would probably be best to enhance the above by leveraging Lucene in some way to increase its performance.
Further, these items should only be created/edited by advanced users or developers familiar with ASP.NET code — how else would one be able to populate the Type and Property Name fields in a Variable item?
Despite these, just imagine the big smiley your boss will send your way when you say new Standard Values variables no longer require any code changes coupled with a deployment. I hope that smiley puts a big smile on your face! 🙂
Put Sitecore to Work for You: Build Custom Task Agents
How many times have you seen some manual process and thought to yourself how much easier your life would be easier if that process were automated?
Custom Sitecore task agents could be of assistance on achieving some automation in Sitecore.
Last night, I built a custom task agent that deletes “expired” items from the recycle bin in all three Sitecore databases — items that have been sitting in the recycle bin after a specified number of days.
I came up with this idea after remembering an instance I had seen in the past where there were so many items in the recycle bin, finding an item to restore would be more difficult than finding a needle in a haystack.
Using .NET reflector, I looked at how Sitecore.Tasks.UrlAgent in Sitecore.Kernel.dll was coded to see if I had to do anything special when building my agent — an example would be ascertaining whether I needed to inherit from a custom base class — and also looked at code in Sitecore.Shell.Applications.Archives.RecycleBin.RecycleBinPage in Sitecore.Client.dll coupled with Sitecore.Shell.Framework.Commands.Archives.Delete in Sitecore.Kernel.dll to figure out how to permanently delete items in the recycle bin.
After doing my research in those two assemblies, I came up with this custom task agent:
using System;
using System.Collections.Generic;
using System.Linq;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Data.Archiving;
using Sitecore.Diagnostics;
namespace Sitecore.Sandbox.Tasks
{
public class RecycleBinCleanupAgent
{
private const string ArchiveName = "recyclebin";
private static readonly char[] Delimiters = new char[] { ',', '|' };
public IEnumerable<string> DatabaseNames { get; set; }
private IEnumerable<Database> _Databases;
private IEnumerable<Database> Databases
{
get
{
if (_Databases == null)
{
_Databases = GetDatabases();
}
return _Databases;
}
}
public int NumberOfDaysUntilExpiration { get; set; }
public bool Enabled { get; set; }
public bool LogActivity { get; set; }
public RecycleBinCleanupAgent(string databases)
{
SetDatabases(databases);
}
private void SetDatabases(string databases)
{
Assert.ArgumentNotNullOrEmpty(databases, "databases");
DatabaseNames = databases.Split(Delimiters, StringSplitOptions.RemoveEmptyEntries).Select(database => database.Trim()).ToList();
}
public void Run()
{
if (Enabled)
{
RemoveEntriesInAllDatabases();
}
}
private void RemoveEntriesInAllDatabases()
{
DateTime expired = GetExpiredDateTime();
if (expired == DateTime.MinValue)
{
return;
}
foreach (Database database in Databases)
{
RemoveEntries(database, expired);
}
}
private DateTime GetExpiredDateTime()
{
if (NumberOfDaysUntilExpiration > 0)
{
return DateTime.Now.AddDays(-1 * NumberOfDaysUntilExpiration).ToUniversalTime();
}
return DateTime.MinValue;
}
private void RemoveEntries(Database database, DateTime expired)
{
int deletedEntriesCount = RemoveEntries(GetArchive(database), expired);
LogInfo(deletedEntriesCount, database.Name);
}
private static int RemoveEntries(Archive archive, DateTime expired)
{
IEnumerable<ArchiveEntry> archiveEntries = GetAllEntries(archive);
int deletedEntriesCount = 0;
foreach (ArchiveEntry archiveEntry in archiveEntries)
{
if (ShouldDeleteEntry(archiveEntry, expired))
{
archive.RemoveEntries(CreateNewArchiveQuery(archiveEntry));
deletedEntriesCount++;
}
}
return deletedEntriesCount;
}
private static IEnumerable<ArchiveEntry> GetAllEntries(Archive archive)
{
Assert.ArgumentNotNull(archive, "archive");
return archive.GetEntries(0, archive.GetEntryCount()); ;
}
private static bool ShouldDeleteEntry(ArchiveEntry archiveEntry, DateTime expired)
{
Assert.ArgumentNotNull(archiveEntry, "archiveEntry");
Assert.ArgumentCondition(expired > DateTime.MinValue, "expired", "expired must be set!");
return archiveEntry.ArchiveDate <= expired;
}
private static ArchiveQuery CreateNewArchiveQuery(ArchiveEntry archiveEntry)
{
Assert.ArgumentNotNull(archiveEntry, "archiveEntry");
return CreateNewArchiveQuery(archiveEntry.ArchivalId);
}
private static ArchiveQuery CreateNewArchiveQuery(Guid archivalId)
{
Assert.ArgumentCondition(archivalId != Guid.Empty, "archivalId", "archivalId must be set!");
return new ArchiveQuery { ArchivalId = archivalId };
}
private void LogInfo(int deletedEntriesCount, string databaseName)
{
bool canLogInfo = LogActivity
&& deletedEntriesCount > 0
&& !string.IsNullOrEmpty(databaseName);
if (canLogInfo)
{
Log.Info(CreateNewLogEntry(deletedEntriesCount, databaseName, ArchiveName), this);
}
}
private static string CreateNewLogEntry(int expiredEntryCount, string databaseName, string archiveName)
{
return string.Format("{0} expired archive entries permanently deleted (database: {1}, archive: {2})", expiredEntryCount, databaseName, archiveName);
}
private IEnumerable<Database> GetDatabases()
{
if (DatabaseNames != null)
{
return DatabaseNames.Select(database => Factory.GetDatabase(database)).ToList();
}
return new List<Database>();
}
private static Archive GetArchive(Database database)
{
Assert.ArgumentNotNull(database, "database");
return ArchiveManager.GetArchive(ArchiveName, database);
}
}
}
Basically, it loops over all recycle bin archived entries in all specified databases after an allotted time interval — the time interval and target databases are set in a patch config file you will see below — and removes an entry when its archival date is older than the minimum expiration date — a date I derive from the NumberOfDaysUntilExpiration setting:
<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<scheduling>
<agent type="Sitecore.Sandbox.Tasks.RecycleBinCleanupAgent, Sitecore.Sandbox" method="Run" interval="01:00:00">
<param desc="databases">core, master, web</param>
<NumberOfDaysUntilExpiration>30</NumberOfDaysUntilExpiration>
<LogActivity>true</LogActivity>
<Enabled>true</Enabled>
</agent>
</scheduling>
</sitecore>
</configuration>
I had 89 items in my master database’s recycle bin before my task agent ran:
I walked away for a bit to watch some television, eat dinner, and surf Twitter for a bit, and phone my brother. I then returned to see the following in my Sitecore log:
I went into the recycle bin in my master database, and saw there were 5 items left after my task agent executed:
As you can see, my custom task agent deleted expired recycle bin items as designed. 🙂
Who Just Published That? Log Publishing Statistics in the Sitecore Client
Time and time again, I keep hearing about content in Sitecore being published prematurely, and this usually occurs by accident — perhaps a cat walks across the malefactor’s keyboard — although I have a feeling something else might driving these erroneous publishes.
However, in some instances, the malefactor will not fess up to invoking said publish.
Seeking out who had published a Sitecore item is beyond the know how of most end users, and usually requires an advanced user or developer to fish around in the Sitecore log to ascertain who published what and when.
Here’s an example of publishing information in my local sandbox’s instance of a publish I had done earlier this evening:
Given that I keep hearing about this happening, I decided it would be a great exercise to develop a feature to bring publishing information into the Sitecore client. We can accomplish this by building a custom PublishItemProcessor pipeline to log statistics into publishing fields.
First, I created a template containing fields to hold publishing information.
These fields will only keep track of who published an item last, similar to how the fields in the Statistics section in the Standard Template.
Next, I set the title of my fields to not show underscores:
I then added my template to the Standard Template:
Now that my publishing fields are in Sitecore, It’s time to build a custom PublishItemProcessor (Sitecore.Publishing.Pipelines.PublishItem.PublishItemProcessor):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Data.Fields;
using Sitecore.Diagnostics;
using Sitecore.Publishing.Pipelines.PublishItem;
using Sitecore.SecurityModel;
namespace Sitecore.Sandbox.Pipelines.Publishing
{
public class UpdatePublishingStatistics : PublishItemProcessor
{
private const string PublishedFieldName = "__Published";
private const string PublishedByFieldName = "__Published By";
public override void Process(PublishItemContext context)
{
SetPublishingStatisticsFields(context);
}
private void SetPublishingStatisticsFields(PublishItemContext context)
{
Assert.ArgumentNotNull(context, "context");
Assert.ArgumentNotNull(context.PublishOptions, "context.PublishOptions");
Assert.ArgumentNotNull(context.PublishOptions.SourceDatabase, "context.PublishOptions.SourceDatabase");
Assert.ArgumentNotNull(context.PublishOptions.TargetDatabase, "context.PublishOptions.TargetDatabase");
Assert.ArgumentCondition(!ID.IsNullOrEmpty(context.ItemId), "context.ItemId", "context.ItemId must be set!");
Assert.ArgumentNotNull(context.User, "context.User");
SetPublishingStatisticsFields(context.PublishOptions.SourceDatabase, context.ItemId, context.User.Name);
SetPublishingStatisticsFields(context.PublishOptions.TargetDatabase, context.ItemId, context.User.Name);
}
private void SetPublishingStatisticsFields(Database database, ID itemId, string userName)
{
Assert.ArgumentNotNull(database, "database");
Item item = TryGetItem(database, itemId);
if (HasPublishingStatisticsFields(item))
{
SetPublishingStatisticsFields(item, DateUtil.IsoNow, userName);
}
}
private void SetPublishingStatisticsFields(Item item, string isoDateTime, string userName)
{
Assert.ArgumentNotNull(item, "item");
Assert.ArgumentNotNullOrEmpty(isoDateTime, "isoDateTime");
Assert.ArgumentNotNullOrEmpty(userName, "userName");
using (new SecurityDisabler())
{
item.Editing.BeginEdit();
item.Fields[PublishedFieldName].Value = DateUtil.IsoNow;
item.Fields[PublishedByFieldName].Value = userName;
item.Editing.EndEdit();
}
}
private Item TryGetItem(Database database, ID itemId)
{
try
{
return database.Items[itemId];
}
catch (Exception ex)
{
Log.Error(this.ToString(), ex, this);
}
return null;
}
private static bool HasPublishingStatisticsFields(Item item)
{
Assert.ArgumentNotNull(item, "item");
return item.Fields[PublishedFieldName] != null
&& item.Fields[PublishedByFieldName] != null;
}
}
}
My PublishItemProcessor sets the publishing statistics on my newly added fields on the item in both the source and target databases, and only when the publishing fields exist on the published item.
I then added my PublishItemProcessor after the UpdateStatistics PublishItemProcessor in a new patch config file:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<pipelines>
<publishItem>
<processor type="Sitecore.Sandbox.Pipelines.Publishing.UpdatePublishingStatistics, Sitecore.Sandbox"
patch:after="processor[@type='Sitecore.Publishing.Pipelines.PublishItem.UpdateStatistics, Sitecore.Kernel']" />
</publishItem>
</pipelines>
</sitecore>
</configuration>
I created a new page for testing, put some dummy data in some fields and then published:
Beware — you can no longer hide after publishing when you shouldn’t! 🙂
Manipulate Field Values in a Custom Sitecore Web Forms for Marketers DataProvider
In my Experiments with Field Data Encryption in Sitecore article, I briefly mentioned designing a custom Web Forms for Marketers (WFFM) DataProvider that uses the decorator pattern for encrypting and decrypting field values before saving and retrieving field values from the WFFM database.
From the time I penned that article up until now, I have been feeling a bit guilty that I may have left you hanging by not going into the mechanics around how I did that — I built that DataProvider for my company to be used in one of our healthcare specific content management modules.
To make up for not showing you this, I decided to build another custom WFFM DataProvider — one that will replace periods with smiley faces and the word “Sitecore” with “Sitecore®”, case insensitively.
First, I defined an interface for utility classes that will manipulate objects for us. All manipulators will consume an object of a specified type, manipulate that object in some way, and then return the maniputed object to the manipulator object’s client:
namespace Sitecore.Sandbox.Utilities.Manipulators.Base
{
public interface IManipulator<T>
{
T Manipulate(T source);
}
}
The first manipulator I built is a string manipulator. Basically, this object will take in a string and replace a specified substring with another:
namespace Sitecore.Sandbox.Utilities.Manipulators.Base
{
public interface IStringReplacementManipulator : IManipulator<string>
{
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Sitecore.Diagnostics;
using Sitecore.Sandbox.Utilities.Manipulators.Base;
namespace Sitecore.Sandbox.Utilities.Manipulators
{
public class StringReplacementManipulator : IStringReplacementManipulator
{
private string PatternToReplace { get; set; }
private string ReplacementString { get; set; }
private bool IgnoreCase { get; set; }
private StringReplacementManipulator(string patternToReplace, string replacementString)
: this(patternToReplace, replacementString, false)
{
}
private StringReplacementManipulator(string patternToReplace, string replacementString, bool ignoreCase)
{
SetPatternToReplace(patternToReplace);
SetReplacementString(replacementString);
SetIgnoreCase(ignoreCase);
}
private void SetPatternToReplace(string patternToReplace)
{
Assert.ArgumentNotNullOrEmpty(patternToReplace, "patternToReplace");
PatternToReplace = patternToReplace;
}
private void SetReplacementString(string replacementString)
{
ReplacementString = replacementString;
}
private void SetIgnoreCase(bool ignoreCase)
{
IgnoreCase = ignoreCase;
}
public string Manipulate(string source)
{
Assert.ArgumentNotNullOrEmpty(source, "source");
RegexOptions regexOptions = RegexOptions.None;
if (IgnoreCase)
{
regexOptions = RegexOptions.IgnoreCase;
}
return Regex.Replace(source, PatternToReplace, ReplacementString, regexOptions);
}
public static IStringReplacementManipulator CreateNewStringReplacementManipulator(string patternToReplace, string replacementString)
{
return new StringReplacementManipulator(patternToReplace, replacementString);
}
public static IStringReplacementManipulator CreateNewStringReplacementManipulator(string patternToReplace, string replacementString, bool ignoreCase)
{
return new StringReplacementManipulator(patternToReplace, replacementString, ignoreCase);
}
}
}
Clients of this class can choose to have substrings replaced in a case sensitive or insensitive manner.
By experimenting in a custom WFFM DataProvider and investigating code via .NET reflector in the WFFM assemblies, I discovered I had to create a custom object that implements Sitecore.Forms.Data.IField.
Out of the box, WFFM uses Sitecore.Forms.Data.DefiniteField — a class that implements Sitecore.Forms.Data.IField, albeit this class is declared internal and cannot be reused outside of the Sitecore.Forms.Core.dll assembly.
When I attempted to change the Value property of this object in a custom WFFM DataProvider, changes did not stick for some reason — a reason that I have not definitely ascertained.
However, to get around these lost Value property changes, I created a custom object that implements Sitecore.Forms.Data.IField:
using System;
using Sitecore.Forms.Data;
namespace Sitecore.Sandbox.Utilities.Manipulators.DTO
{
public class WFFMField : IField
{
public string Data { get; set; }
public Guid FieldId { get; set; }
public string FieldName { get; set; }
public IForm Form { get; set; }
public Guid Id { get; internal set; }
public string Value { get; set; }
}
}
Next, I built a WFFM Field collection manipulator — an IEnumerable of Sitecore.Forms.Data.IField defined in Sitecore.Forms.Core.dll — using the DTO defined above:
using System.Collections.Generic;
using Sitecore.Forms.Data;
namespace Sitecore.Sandbox.Utilities.Manipulators.Base
{
public interface IWFFMFieldsManipulator : IManipulator<IEnumerable<IField>>
{
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Diagnostics;
using Sitecore.Forms.Data;
using Sitecore.Sandbox.Utilities.Manipulators.Base;
using Sitecore.Sandbox.Utilities.Manipulators.DTO;
namespace Sitecore.Sandbox.Utilities.Manipulators
{
public class WFFMFieldsManipulator : IWFFMFieldsManipulator
{
private IEnumerable<IStringReplacementManipulator> FieldValueManipulators { get; set; }
private WFFMFieldsManipulator(params IStringReplacementManipulator[] fieldValueManipulator)
: this(fieldValueManipulator.AsEnumerable())
{
}
private WFFMFieldsManipulator(IEnumerable<IStringReplacementManipulator> fieldValueManipulators)
{
SetFieldValueManipulator(fieldValueManipulators);
}
private void SetFieldValueManipulator(IEnumerable<IStringReplacementManipulator> fieldValueManipulators)
{
Assert.ArgumentNotNull(fieldValueManipulators, "fieldValueManipulators");
foreach (IStringReplacementManipulator fieldValueManipulator in fieldValueManipulators)
{
Assert.ArgumentNotNull(fieldValueManipulator, "fieldValueManipulator");
}
FieldValueManipulators = fieldValueManipulators;
}
public IEnumerable<IField> Manipulate(IEnumerable<IField> fields)
{
IList<IField> maniuplatdFields = new List<IField>();
foreach (IField field in fields)
{
maniuplatdFields.Add(MainpulateFieldValue(field));
}
return maniuplatdFields;
}
private IField MainpulateFieldValue(IField field)
{
IField maniuplatedField = CreateNewWFFMField(field);
if (maniuplatedField != null)
{
maniuplatedField.Value = ManipulateString(maniuplatedField.Value);
}
return maniuplatedField;
}
private static IField CreateNewWFFMField(IField field)
{
if(field != null)
{
return new WFFMField
{
Data = field.Data,
FieldId = field.FieldId,
FieldName = field.FieldName,
Form = field.Form,
Id = field.Id,
Value = field.Value
};
}
return null;
}
private string ManipulateString(string stringToManipulate)
{
if (string.IsNullOrEmpty(stringToManipulate))
{
return string.Empty;
}
string manipulatedString = stringToManipulate;
foreach(IStringReplacementManipulator fieldValueManipulator in FieldValueManipulators)
{
manipulatedString = fieldValueManipulator.Manipulate(manipulatedString);
}
return manipulatedString;
}
public static IWFFMFieldsManipulator CreateNewWFFMFieldsManipulator(params IStringReplacementManipulator[] fieldValueManipulators)
{
return new WFFMFieldsManipulator(fieldValueManipulators);
}
public static IWFFMFieldsManipulator CreateNewWFFMFieldsManipulator(IEnumerable<IStringReplacementManipulator> fieldValueManipulators)
{
return new WFFMFieldsManipulator(fieldValueManipulators);
}
}
}
This manipulator consumes a collection of string manipulators and delegates to these for making changes to WFFM field values.
Now, it’s time to create a custom WFFM DataProvider that uses our manipulators defined above.
In my local sandbox Sitecore instance, I’m using SQLite for my WFFM module, and must decorate an instance of Sitecore.Forms.Data.DataProviders.SQLite.SQLiteWFMDataProvider — although this approach would be the same using MS SQL or Oracle since all WFFM DataProviders should inherit from the abstract class Sitecore.Forms.Data.DataProviders.WFMDataProviderBase:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Diagnostics;
using Sitecore.Forms.Data;
using Sitecore.Forms.Data.DataProviders;
using Sitecore.Forms.Data.DataProviders.SQLite;
using Sitecore.Sandbox.Translation.Base;
using Sitecore.Sandbox.Utilities.Manipulators.Base;
using Sitecore.Sandbox.Utilities.Manipulators;
namespace Sitecore.Sandbox.WFFM.Data.DataProviders
{
public class FieldValueManipulationSQLiteWFMDataProvider : WFMDataProviderBase
{
private static readonly IStringReplacementManipulator RegisteredTrademarkManipulator = StringReplacementManipulator.CreateNewStringReplacementManipulator("sitecore", " Sitecore®", true);
private static readonly IStringReplacementManipulator PeriodsToSmiliesManipulator = StringReplacementManipulator.CreateNewStringReplacementManipulator("\\.", " :)");
private static readonly IWFFMFieldsManipulator FieldsManipulator = WFFMFieldsManipulator.CreateNewWFFMFieldsManipulator(RegisteredTrademarkManipulator, PeriodsToSmiliesManipulator);
private WFMDataProviderBase InnerProvider { get; set; }
public FieldValueManipulationSQLiteWFMDataProvider()
: this(CreateNewSQLiteWFMDataProvider())
{
}
public FieldValueManipulationSQLiteWFMDataProvider(string connectionString)
: this(CreateNewSQLiteWFMDataProvider(connectionString))
{
}
public FieldValueManipulationSQLiteWFMDataProvider(WFMDataProviderBase innerProvider)
{
SetInnerProvider(innerProvider);
}
private void SetInnerProvider(WFMDataProviderBase innerProvider)
{
Assert.ArgumentNotNull(innerProvider, "innerProvider");
InnerProvider = innerProvider;
}
private static WFMDataProviderBase CreateNewSQLiteWFMDataProvider()
{
return new SQLiteWFMDataProvider();
}
private static WFMDataProviderBase CreateNewSQLiteWFMDataProvider(string connectionString)
{
Assert.ArgumentNotNullOrEmpty(connectionString, "connectionString");
return new SQLiteWFMDataProvider(connectionString);
}
public override void ChangeStorage(Guid formItemId, string newStorage)
{
InnerProvider.ChangeStorage(formItemId, newStorage);
}
public override void ChangeStorageForForms(IEnumerable<Guid> ids, string storageName)
{
InnerProvider.ChangeStorageForForms(ids, storageName);
}
public override void DeleteForms(IEnumerable<Guid> formSubmitIds)
{
InnerProvider.DeleteForms(formSubmitIds);
}
public override void DeleteForms(Guid formItemId, string storageName)
{
InnerProvider.DeleteForms(formItemId, storageName);
}
public override IEnumerable<IPool> GetAbundantPools(Guid fieldId, int top, out int total)
{
return InnerProvider.GetAbundantPools(fieldId, top, out total);
}
public override IEnumerable<IForm> GetForms(QueryParams queryParams, out int total)
{
return InnerProvider.GetForms(queryParams, out total);
}
public override IEnumerable<IForm> GetFormsByIds(IEnumerable<Guid> ids)
{
return InnerProvider.GetFormsByIds(ids);
}
public override int GetFormsCount(Guid formItemId, string storageName, string filter)
{
return InnerProvider.GetFormsCount(formItemId, storageName, filter);
}
public override IEnumerable<IPool> GetPools(Guid fieldId)
{
return InnerProvider.GetPools(fieldId);
}
public override void InsertForm(IForm form)
{
ManipulateFields(form);
InnerProvider.InsertForm(form);
}
public override void ResetPool(Guid fieldId)
{
InnerProvider.ResetPool(fieldId);
}
public override IForm SelectSingleForm(Guid fieldId, string likeValue)
{
return InnerProvider.SelectSingleForm(fieldId, likeValue);
}
public override bool UpdateForm(IForm form)
{
ManipulateFields(form);
return InnerProvider.UpdateForm(form);
}
private static void ManipulateFields(IForm form)
{
Assert.ArgumentNotNull(form, "form");
Assert.ArgumentNotNull(form.Field, "form.Field");
form.Field = FieldsManipulator.Manipulate(form.Field);
}
}
}
This custom DataProvider creates an instance of Sitecore.Forms.Data.DataProviders.SQLite.SQLiteWFMDataProvider and delegates method calls to it.
However, before delegating to insert and update method calls, forms fields are manipulated via our manipulators objects — our manipulators will replace periods with smiley faces, and the word “Sitecore” with “Sitecore®”.
I then had to configure WFFM to use my custom DataProvider above in /App_Config/Include/forms.config:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:x="http://www.sitecore.net/xmlconfig/"> <sitecore> <!-- A bunch of stuff here --> <!-- SQLite --> <formsDataProvider type="Sitecore.Sandbox.WFFM.Data.DataProviders.FieldValueManipulationSQLiteWFMDataProvider,Sitecore.Sandbox"> <param desc="connection string">Data Source=/data/sitecore_webforms.db;version=3;BinaryGUID=true</param> </formsDataProvider> <!-- A bunch of stuff here --> </sitecore> </configuration>
For testing, I build a random WFFM form containing three fields, and created a page item to hold this form.
I then navigated to my form page and filled it in:
I then clicked the Submit button:
I then opened up the Form Reports for my form:
As you can see, it all gelled together nicely. 🙂
Get Hooked on Hooks: Create a Custom Hook in Sitecore
Yesterday evening, I decided to fish around in my local Sitecore instance’s Web.config to look for customization opportunities — doing this definitely beats vegging out in front of the television any day — and thought it would be an interesting exercise to create a custom hook.
Before I dive into the custom hook I built, I’d like to discuss what a Sitecore hook is.
You can consider a hook to be an object containing code that you would like executed in your Sitecore instance, but see no logical place to put said code.
You’re probably saying to yourself “Mike, that is an extremely generic and ridiculous definition.” I don’t blame you for thinking this, although I can’t really define what a hook is any better than that. If you have a better definition, please leave a comment.
However, to put things into context that might aid in defining what a hook could be, a hook is usually defined as an object containing code that is executed periodically by a defined configuration setting time interval — albeit this isn’t a mandatory constraint since one could inject any code via a hook, as long as that hook implements the Sitecore.Events.Hooks.IHook interface.
This interface defines one method — the Initialize() method — which has a very simple signature: it takes in no parameters and does not return anything, thus giving you lots of freedom around how you implement your hook.
Out of the box, Sitecore employs two hooks:
- Sitecore.Diagnostics.HealthMonitorHook – a hook that launches a pipeline periodically to log cache, memory, and performance counter information to the Sitecore log.
- Sitecore.Diagnostics.MemoryMonitorHook – a hook that monitors memory periodically on the server, and clears caches/invokes the garbage collector when a defined thresholds are exceeded and settings defined in the Web.config allow for these actions.
Both hooks are defined in Sitecore.Kernel.dll.
Keeping with the monitoring theme of the prepackaged hooks, I decided to build a hook that monitors the size of my Sitecore databases after an elapsed period of time, and logs this information into my Sitecore log.
First, I created a data transfer object (DTO) that represents a size snapshot of a database:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sitecore.Sandbox.Utilities.Database.DTO
{
public class DatabaseStatistics
{
public string Name { get; set; }
public string Size { get; set; }
public string UnallocatedSpace { get; set; }
}
}
The above DTO is created and returned by a class that gets information out of a database using the Sitecore.Data.SqlServer.SqlServerDataApi utility class used by Sitecore for database operations in MS SQL Server:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Sandbox.Utilities.Database.DTO;
namespace Sitecore.Sandbox.Utilities.Database.Base
{
public interface IDatabaseStatisticsGatherer
{
DatabaseStatistics GetDatabaseStatistics();
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Data.DataProviders.Sql;
using Sitecore.Data.SqlServer;
using Sitecore.Diagnostics;
using Sitecore.Sandbox.Utilities.Database.Base;
using Sitecore.Sandbox.Utilities.Database.DTO;
namespace Sitecore.Sandbox.Utilities.Database
{
public class DatabaseStatisticsGatherer : IDatabaseStatisticsGatherer
{
private const string GetSizeStaticsSQL = "exec sp_spaceused"; // this sproc gives database size information
private SqlDataApi SqlDataApi { get; set; }
private DatabaseStatisticsGatherer(string connectionString)
: this(CreateNewSqlServerDataApi(connectionString))
{
}
private DatabaseStatisticsGatherer(SqlDataApi sqlDataApi)
{
SetSqlDataApi(sqlDataApi);
}
private void SetSqlDataApi(SqlDataApi sqlDataApi)
{
Assert.ArgumentNotNull(sqlDataApi, "sqlDataApi");
SqlDataApi = sqlDataApi;
}
public DatabaseStatistics GetDatabaseStatistics()
{
IEnumerable<string> columnValues = SqlDataApi.GetStringList(GetSizeStaticsSQL, new object[0]);
return CreateNewDatabaseStatistics(columnValues);
}
private static DatabaseStatistics CreateNewDatabaseStatistics(IEnumerable<string> columnValues)
{
if (columnValues == null || columnValues.Count() < 1)
{
return null;
}
return new DatabaseStatistics
{
Name = columnValues.ElementAtOrDefault(0),
Size = columnValues.ElementAtOrDefault(1),
UnallocatedSpace = columnValues.ElementAtOrDefault(2)
};
}
private static SqlDataApi CreateNewSqlServerDataApi(string connectionString)
{
Assert.ArgumentNotNullOrEmpty(connectionString, "connectionString");
return new SqlServerDataApi(connectionString);
}
public static IDatabaseStatisticsGatherer CreateNewDatabaseStatisticsGatherer(string connectionString)
{
return new DatabaseStatisticsGatherer(connectionString);
}
public static IDatabaseStatisticsGatherer CreateNewDatabaseStatisticsGatherer(SqlDataApi sqlDataApi)
{
return new DatabaseStatisticsGatherer(sqlDataApi);
}
}
}
I decided to follow the paradigm set forth by Sitecore.Diagnostics.HealthMonitorHook: having my hook invoke a pipeline after an elapsed period of time. Here are this pipeline’s DTO and the pipeline class itself:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Pipelines;
namespace Sitecore.Sandbox.Pipelines.DatabaseMonitor.DTO
{
public class DatabaseMonitorArgs : PipelineArgs
{
public IEnumerable<string> Databases { get; set; }
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Text;
using Sitecore.Collections;
using Sitecore.Data.Sql;
using Sitecore.Diagnostics;
using Sitecore.Sandbox.Pipelines.DatabaseMonitor.DTO;
using Sitecore.Sandbox.Utilities.Database;
using Sitecore.Sandbox.Utilities.Database.Base;
using Sitecore.Sandbox.Utilities.Database.DTO;
namespace Sitecore.Sandbox.Pipelines.DatabaseMonitor
{
public class DatabaseMonitor
{
public void LogDatabasesSize(DatabaseMonitorArgs args)
{
foreach (string connectionStringKey in args.Databases)
{
LogDatabaseStatistics(connectionStringKey);
}
}
private void LogDatabaseStatistics(string connectionStringKey)
{
LogDatabaseStatistics(GetDatabaseStatistics(connectionStringKey));
}
private static DatabaseStatistics GetDatabaseStatistics(string connectionStringKey)
{
IDatabaseStatisticsGatherer gatherer = DatabaseStatisticsGatherer.CreateNewDatabaseStatisticsGatherer(GetConnectionString(connectionStringKey));
return gatherer.GetDatabaseStatistics();
}
private void LogDatabaseStatistics(DatabaseStatistics statistics)
{
if (statistics == null)
{
return;
}
Log.Info(GetLogEntry(statistics), this);
}
private static string GetConnectionString(string connectionStringKey)
{
Assert.ArgumentNotNullOrEmpty(connectionStringKey, "connectionStringKey");
return ConfigurationManager.ConnectionStrings[connectionStringKey].ConnectionString;
}
private static string GetLogEntry(DatabaseStatistics statistics)
{
Assert.ArgumentNotNull(statistics, "statistics");
return string.Format("Database size statistics: '{0}' (size: {1}, unallocated space: {2})", statistics.Name, statistics.Size, statistics.UnallocatedSpace);
}
}
}
Now, it’s time to hookup my hook. I followed how Sitecore.Diagnostics.HealthMonitorHook uses the AlarmClock class to continuously invoke code after a specified period of time:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Diagnostics;
using Sitecore.Events.Hooks;
using Sitecore.Pipelines;
using Sitecore.Services;
using Sitecore.Text;
using Sitecore.Sandbox.Pipelines.DatabaseMonitor.DTO;
namespace Sitecore.Sandbox.Hooks
{
public class DatabaseMonitorHook : IHook
{
private static readonly char[] Delimiters = new char[] { ',', '|' };
private static AlarmClock _alarmClock;
private IEnumerable<string> Databases { get; set; }
private TimeSpan Interval { get; set; }
private bool Enabled { get; set; }
public DatabaseMonitorHook(string databases, string interval, string enabled)
{
SetDatabases(databases);
SetInterval(interval);
SetEnabled(enabled);
}
private void SetDatabases(string databases)
{
Assert.ArgumentNotNullOrEmpty(databases, "databases");
Databases = databases.Split(Delimiters, StringSplitOptions.RemoveEmptyEntries).Select(database => database.Trim());
}
private void SetInterval(string interval)
{
Assert.ArgumentNotNullOrEmpty(interval, "interval");
Interval = TimeSpan.Parse(interval);
}
private void SetEnabled(string enabled)
{
bool isEnabled;
if (bool.TryParse(enabled, out isEnabled))
{
Enabled = isEnabled;
}
}
private void AlarmClock_Ring(object sender, EventArgs args)
{
Pipeline.Start("databaseMonitor", CreateNewDatabaseMonitorArgs());
}
private DatabaseMonitorArgs CreateNewDatabaseMonitorArgs()
{
return new DatabaseMonitorArgs { Databases = Databases };
}
public void Initialize()
{
if (Enabled && _alarmClock == null)
{
_alarmClock = CreateNewAlarmClock(Interval);
_alarmClock.Ring += new EventHandler<EventArgs>(AlarmClock_Ring);
}
}
private static AlarmClock CreateNewAlarmClock(TimeSpan interval)
{
return new AlarmClock(interval);
}
}
}
I glued everything together via a new config file where I define my new hook and pipeline:
<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
<sitecore>
<hooks>
<hook type="Sitecore.Sandbox.Hooks.DatabaseMonitorHook, Sitecore.Sandbox">
<param desc="databases">core, master, web</param>
<param desc="interval">00:01:00</param>
<param desc="enabled">true</param>
</hook>
</hooks>
<processors>
<databaseMonitor>
<processor type="Sitecore.Sandbox.Pipelines.DatabaseMonitor.DatabaseMonitor, Sitecore.Sandbox" method="LogDatabasesSize"/>
</databaseMonitor>
</processors>
</sitecore>
</configuration>
After all of the above code was compiled and the configuration file was saved, I kick-started my local Sitecore instance by navigating to the home page of my site. I then walked away for a bit. When I returned, I opened up my most recent log file and saw the following:
As shown in my log file entries, my hook along with its supporting classes were all hooked up correctly. 🙂
Rip Out Sitecore Web Forms for Marketers Field Values During a Custom Save Action
A couple of weeks ago, I architected a Web Forms for Marketers (WFFM) solution that sends credit card information to a third-party credit card processor — via a custom form verification step — and blanks out sensitive credit card information before saving into the WFFM database.
Out of the box, WFFM will save credit card information in plain text to its database — yes, the data is hidden in the form report, although is saved to the database as plain text. This information being saved as plain text is a security risk.
One could create a solution similar to what I had done in my article discussing data encryption — albeit it might be in your best interest to avoid any headaches and potential security issues around saving and holding onto credit card information.
I can’t share the solution I built a couple of weeks ago — I built it for my current employer. I will, however, share a similar solution where I blank out the value of a field containing a social security number — a unique identifier of a citizen of the United States — of a person posing a question to the Internal Revenue Service (IRS) of the United States (yes, I chose this topic since tax season is upon us here in the US, and I loathe doing taxes :)).
First, I created a custom Web Forms for Marketers field for social security numbers. It is just a composite control containing three textboxes separated by labels containing hyphens:
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Web.UI;
using System.Web.UI.WebControls;
using Sitecore.Form.Core.Attributes;
using Sitecore.Form.Core.Controls.Data;
using Sitecore.Form.Core.Visual;
using Sitecore.Form.Web.UI.Controls;
namespace Sitecore.Sandbox.WFFM.Controls
{
public class SocialSecurityNumber : InputControl
{
private const string Hyphen = "-";
private static readonly string baseCssClassName = "scfSingleLineTextBorder";
protected TextBox firstPart;
protected System.Web.UI.WebControls.Label firstMiddleSeparator;
protected TextBox middlePart;
protected System.Web.UI.WebControls.Label middleLastSeparator;
protected TextBox lastPart;
public SocialSecurityNumber() : this(HtmlTextWriterTag.Div)
{
firstPart = new TextBox();
firstMiddleSeparator = new System.Web.UI.WebControls.Label { Text = Hyphen };
middlePart = new TextBox();
middleLastSeparator = new System.Web.UI.WebControls.Label { Text = Hyphen };
lastPart = new TextBox();
}
public SocialSecurityNumber(HtmlTextWriterTag tag)
: base(tag)
{
this.CssClass = baseCssClassName;
}
protected override void OnInit(EventArgs e)
{
SetCssClasses();
SetTextBoxeWidths();
SetMaxLengths();
SetTextBoxModes();
AddChildControls();
}
private void SetCssClasses()
{
help.CssClass = "scfSingleLineTextUsefulInfo";
title.CssClass = "scfSingleLineTextLabel";
generalPanel.CssClass = "scfSingleLineGeneralPanel";
}
private void SetTextBoxeWidths()
{
firstPart.Style.Add("width", "40px");
middlePart.Style.Add("width", "35px");
lastPart.Style.Add("width", "50px");
}
private void SetMaxLengths()
{
firstPart.MaxLength = 3;
middlePart.MaxLength = 2;
lastPart.MaxLength = 4;
}
private void SetTextBoxModes()
{
firstPart.TextMode = TextBoxMode.SingleLine;
middlePart.TextMode = TextBoxMode.SingleLine;
lastPart.TextMode = TextBoxMode.SingleLine;
}
private void AddChildControls()
{
Controls.AddAt(0, generalPanel);
Controls.AddAt(0, title);
generalPanel.Controls.Add(firstPart);
generalPanel.Controls.Add(firstMiddleSeparator);
generalPanel.Controls.Add(middlePart);
generalPanel.Controls.Add(middleLastSeparator);
generalPanel.Controls.Add(lastPart);
generalPanel.Controls.Add(help);
}
public override string ID
{
get
{
return firstPart.ID;
}
set
{
title.ID = string.Concat(value, "_text");
firstPart.ID = value;
base.ID = string.Concat(value, "_scope");
title.AssociatedControlID = firstPart.ID;
}
}
public override ControlResult Result
{
get
{
return GetNewControlResult();
}
}
private ControlResult GetNewControlResult()
{
TrimAllTextBoxes();
return new ControlResult(ControlName, GetValue(), string.Empty);
}
private string GetValue()
{
bool hasValue = !string.IsNullOrEmpty(firstPart.Text)
&& !string.IsNullOrEmpty(middlePart.Text)
&& !string.IsNullOrEmpty(lastPart.Text);
if (hasValue)
{
return string.Concat(firstPart.Text, firstMiddleSeparator.Text, middlePart.Text, middleLastSeparator.Text, lastPart.Text);
}
return string.Empty;
}
private void TrimAllTextBoxes()
{
firstPart.Text = firstPart.Text.Trim();
middlePart.Text = middlePart.Text.Trim();
lastPart.Text = lastPart.Text.Trim();
}
}
}
I then registered this custom field in Sitecore. I added my custom field item under /sitecore/system/Modules/Web Forms for Marketers/Settings/Field Types/Custom:
Next, I decided to create a generic interface that defines objects that will excise or rip out values from a given object of a certain type, and returns a new object of another type — although both types could be the same:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sitecore.Sandbox.Utilities.Excisors.Base
{
public interface IExcisor<T, U>
{
U Excise(T source);
}
}
My WFFM excisor will take in a collection of WFFM fields and return a new collection where the targeted field values — field values that I don’t want saved into the WFFM database — are set to the empty string.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Form.Core.Controls.Data;
using Sitecore.Form.Core.Client.Data.Submit;
namespace Sitecore.Sandbox.Utilities.Excisors.Base
{
public interface IWFFMFieldValuesExcisor : IExcisor<AdaptedResultList, AdaptedResultList>
{
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Diagnostics;
using Sitecore.Form.Core.Client.Data.Submit;
using Sitecore.Form.Core.Controls.Data;
using Sitecore.Sandbox.Utilities.Excisors.Base;
namespace Sitecore.Sandbox.Utilities.Excisors
{
public class WFFMFieldValuesExcisor : IWFFMFieldValuesExcisor
{
private IEnumerable<string> FieldNamesForExtraction { get; set; }
private WFFMFieldValuesExcisor(IEnumerable<string> fieldNamesForExtraction)
{
SetFieldNamesForExtraction(fieldNamesForExtraction);
}
private void SetFieldNamesForExtraction(IEnumerable<string> fieldNamesForExtraction)
{
Assert.ArgumentNotNull(fieldNamesForExtraction, "fieldNamesForExtraction");
FieldNamesForExtraction = fieldNamesForExtraction;
}
public AdaptedResultList Excise(AdaptedResultList fields)
{
if(fields == null || fields.Count() < 1)
{
return fields;
}
List<AdaptedControlResult> adaptedControlResults = new List<AdaptedControlResult>();
foreach(AdaptedControlResult field in fields)
{
adaptedControlResults.Add(GetExtractValueFieldIfApplicable(field));
}
return adaptedControlResults;
}
private AdaptedControlResult GetExtractValueFieldIfApplicable(AdaptedControlResult field)
{
if(ShouldExtractFieldValue(field))
{
return GetExtractedValueField(field);
}
return field;
}
private bool ShouldExtractFieldValue(ControlResult field)
{
return FieldNamesForExtraction.Contains(field.FieldName);
}
private static AdaptedControlResult GetExtractedValueField(ControlResult field)
{
return new AdaptedControlResult(CreateNewControlResultWithEmptyValue(field), true);
}
private static ControlResult CreateNewControlResultWithEmptyValue(ControlResult field)
{
ControlResult controlResult = new ControlResult(field.FieldName, string.Empty, field.Parameters);
controlResult.FieldID = field.FieldID;
return controlResult;
}
public static IWFFMFieldValuesExcisor CreateNewWFFMFieldValuesExcisor(IEnumerable<string> fieldNamesForExtraction)
{
return new WFFMFieldValuesExcisor(fieldNamesForExtraction);
}
}
}
For scalability purposes and to avoid hard-coding field name values, I put my targeted fields — I only have one at the moment — into a config file:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <settings> <setting name="ExciseFields.SocialSecurityNumberField" value="Social Security Number" /> </settings> </sitecore> </configuration>
I then created a custom save to database action where I call my utility class to rip out my specified targeted fields, and pass that through onto the base save to database action class:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Configuration;
using Sitecore.Data;
using Sitecore.Form.Core.Client.Data.Submit;
using Sitecore.Form.Submit;
using Sitecore.Sandbox.Utilities.Excisors;
using Sitecore.Sandbox.Utilities.Excisors.Base;
namespace Sitecore.Sandbox.WFFM.Actions
{
public class ExciseFieldValuesThenSaveToDatabase : SaveToDatabase
{
private static readonly IEnumerable<string> FieldsToExcise = new string[] { Settings.GetSetting("ExciseFields.SocialSecurityNumberField") };
public override void Execute(ID formId, AdaptedResultList fields, object[] data)
{
base.Execute(formId, ExciseFields(fields), data);
}
private AdaptedResultList ExciseFields(AdaptedResultList fields)
{
IWFFMFieldValuesExcisor excisor = WFFMFieldValuesExcisor.CreateNewWFFMFieldValuesExcisor(FieldsToExcise);
return excisor.Excise(fields);
}
}
}
I created a new item to register my custom save to database action under /sitecore/system/Modules/Web Forms for Marketers/Settings/Actions/Save Actions:

I then created my form:
I set this form on a new page item, and published all of my changes above.
Now, it’s time to test this out. I navigated to my form, and filled it in as an irate tax payer might:
Looking at my form’s report, I see that the social security number was blanked out before being saved to the database:
A similar solution could also be used to add new field values into the collection of fields — values of fields that wouldn’t be available on the form, but should be displayed in the form report. An example might include a transaction ID or approval ID returned from a third-party credit card processor. Instead of ripping values, values would be inserted into the collection of fields, and then passed along to the base save to database action class.
Have a Field Day With Custom Sitecore Fields
This week, I’ve been off from work, and have been spending most of my downtime experimenting with Sitecore-ry development things — no, not you Mike — primarily poking around the core database to find things I can override or extend — for a thought provoking article about whether to override or extend things in Sitecore, check out John West’s blog post on this topic — and decided get my hands dirty by creating a custom Sitecore field.
This is something I feel isn’t being done enough by developers out in the wild — myself included — so I decided to take a stab at it, and share how you could go about accomplishing this.
The first field I created was a custom Single-Line Text field with a clear button. Since the jQuery library is available in the Sitecore client, I utilized a jQuery plugin to help me with this. It’s not a very robust plugin — I did have to make a couple of changes due to issues I encountered which I will omit from this article — albeit the purpose of me creating this field was to see how difficult it truly is.
As you can see below, creating a custom Single-Line Text isn’t difficult at all. All one has to do is subclass Sitecore.Shell.Applications.ContentEditor.Text in Sitecore.Kernel.dll, and ultimately override the DoRender() method:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.UI;
using System.Web;
namespace Sitecore.Sandbox.Shell.Applications.ContentEditor
{
public class ClearableSingleLineText : Sitecore.Shell.Applications.ContentEditor.Text
{
protected override void DoRender(HtmlTextWriter output)
{
SetWidthAndHeightStyle();
RenderMyHtml(output);
RenderChildren(output);
RenderAutoSizeJavascript(output);
}
private void RenderMyHtml(HtmlTextWriter output)
{
const string htmlFormat = "<input{0}/>";
output.Write(string.Format(htmlFormat, ControlAttributes));
}
private void RenderAutoSizeJavascript(HtmlTextWriter output)
{
/* I'm calling the jQuery() function directly since $() is defined for prototype.js in the Sitecore client */
const string jsFormat = "<script type=\"text/javascript\">jQuery('#{0}').clearable();</script>";
output.Write(string.Format(jsFormat, ID));
}
}
}
I then added a script reference to the jQuery plugin above, and embedded some css in /sitecore/shell/Applications/Content Manager/Default.aspx:
<script type="text/javaScript" language="javascript" src="/sitecore/shell/Controls/Lib/jQuery/jquery.clearable.js"></script>
<style type="text/css">
/* Most of this css was provided by the jquery.clearable plugin author */
a.clearlink
{
background: url("/img/close-button.png") no-repeat scroll 0 0 transparent;
background-position: center center;
cursor: pointer;
display: -moz-inline-stack;
display: inline-block;
zoom:1;
*display:inline;
height: 12px;
width: 12px;
z-index: 2000;
border: 0px solid;
position: relative;
top: -18px;
left: -5px;
float: right;
}
a.clearlink:hover
{
background: url("/img/close-button.png") no-repeat scroll -12px 0 transparent;
background-position: center center;
}
</style>
What I did above doesn’t sit well with me. If a future version of Sitecore makes any changes to this .aspx, these changes might be lost (well, hopefully you would keep a copy of this file in a source control system somewhere).
However, I could not think of a better way of adding this javascript and css. If you know of a better way, please leave a comment.
I then defined my library of controls for my custom fields — not much of a library, since I only defined one field so far 🙂 — in a new patch config file /App_Config/Include/CustomFields.config:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <controlSources> <source mode="on" namespace="Sitecore.Sandbox.Shell.Applications.ContentEditor" assembly="Sitecore.Sandbox" prefix="contentcustom"/> </controlSources> </sitecore> </configuration>
In the core database, I created a new field type for my custom Single-Line Text:
I then added a new field to my template using this new custom field definition in my master database:
Let’s see this new custom field in action.
I typed in some text on my item, followed by clicking the clear button:
As expected, the text that I had typed was removed from the field:
After having done the above, I wanted to see if I could create a different type of custom field — creating a custom Text field was way too easy, and I wanted something more challenging. I figured creating a custom Multilist field might offer a challenge, so I decided to give it a go.
What I came up with was a custom Multilist field containing Sitecore users, instead of Items as options. I don’t know if there is any practicality in creating such a field, but I decided to go with it just for the purpose of creating a custom Multilist field.
First, I created a class to delegate responsibility for getting selected/unselected users in my field — I creating this class just in case I ever wanted to reuse this in a future custom field that should also contain Sitecore users.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Security.Accounts;
namespace Sitecore.Sandbox.Shell.Applications.ContentEditor.Base
{
public interface IUsersField
{
IEnumerable<User> GetSelectedUsers();
IEnumerable<User> GetUnselectedUsers();
string GetProviderUserKey(User user);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Security;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Security.Accounts;
using Sitecore.Text;
using Sitecore.Sandbox.Shell.Applications.ContentEditor.Base;
namespace Sitecore.Sandbox.Shell.Applications.ContentEditor
{
public class UsersField : IUsersField
{
private static readonly string DomainParameterName = Settings.GetSetting("UsersField.DomainParameterName");
private ListString _SelectedUsers;
private ListString SelectedUsers
{
get
{
if (_SelectedUsers == null)
{
_SelectedUsers = new ListString(Value);
}
return _SelectedUsers;
}
}
private IEnumerable<User> _UsersInDomain;
private IEnumerable<User> UsersInDomain
{
get
{
if (_UsersInDomain == null)
{
_UsersInDomain = GetUsersInDomain();
}
return _UsersInDomain;
}
}
private IEnumerable<User> _Users;
private IEnumerable<User> Users
{
get
{
if(_Users == null)
{
_Users = GetUsers();
}
return _Users;
}
}
private string _Domain;
private string Domain
{
get
{
if (string.IsNullOrEmpty(_Domain))
{
_Domain = FieldSettings[DomainParameterName];
}
return _Domain;
}
}
private UrlString _FieldSettings;
private UrlString FieldSettings
{
get
{
if (_FieldSettings == null)
{
_FieldSettings = GetFieldSettings();
}
return _FieldSettings;
}
}
private string Source { get; set; }
private string Value { get; set; }
private UsersField(string source, string value)
{
SetSource(source);
SetValue(value);
}
private void SetSource(string source)
{
Source = source;
}
private void SetValue(string value)
{
Value = value;
}
private IEnumerable<User> GetUsersInDomain()
{
if (!string.IsNullOrEmpty(Domain))
{
return Users.Where(user => IsUserInDomain(user, Domain));
}
return Users;
}
private static IEnumerable<User> GetUsers()
{
IEnumerable<User> users = UserManager.GetUsers();
if (users != null)
{
return users;
}
return new List<User>();
}
private static bool IsUserInDomain(User user, string domain)
{
Assert.ArgumentNotNull(user, "user");
Assert.ArgumentNotNullOrEmpty(domain, "domain");
string userNameLowerCase = user.Profile.UserName.ToLower();
string domainLowerCase = domain.ToLower();
return userNameLowerCase.StartsWith(domainLowerCase);
}
private UrlString GetFieldSettings()
{
try
{
if (!string.IsNullOrEmpty(Source))
{
return new UrlString(Source);
}
}
catch (Exception ex)
{
Log.Error(this.ToString(), ex, this);
}
return new UrlString();
}
public IEnumerable<User> GetSelectedUsers()
{
IList<User> selectedUsers = new List<User>();
foreach (string providerUserKey in SelectedUsers)
{
User selectedUser = UsersInDomain.Where(user => GetProviderUserKey(user) == providerUserKey).FirstOrDefault();
if (selectedUser != null)
{
selectedUsers.Add(selectedUser);
}
}
return selectedUsers;
}
public IEnumerable<User> GetUnselectedUsers()
{
IList<User> unselectedUsers = new List<User>();
foreach (User user in UsersInDomain)
{
if (!IsUserSelected(user))
{
unselectedUsers.Add(user);
}
}
return unselectedUsers;
}
private bool IsUserSelected(User user)
{
string providerUserKey = GetProviderUserKey(user);
return IsUserSelected(providerUserKey);
}
private bool IsUserSelected(string providerUserKey)
{
return SelectedUsers.IndexOf(providerUserKey) > -1;
}
public string GetProviderUserKey(User user)
{
Assert.ArgumentNotNull(user, "user");
MembershipUser membershipUser = Membership.GetUser(user.Profile.UserName);
return membershipUser.ProviderUserKey.ToString();
}
public static IUsersField CreateNewUsersField(string source, string value)
{
return new UsersField(source, value);
}
}
}
I then modified my patch config file defined above (/App_Config/Include/CustomFields.config) with a new setting — a setting that specifies the parameter name for filtering on users’ Sitecore domain:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <controlSources> <source mode="on" namespace="Sitecore.Sandbox.Shell.Applications.ContentEditor" assembly="Sitecore.Sandbox" prefix="contentcustom"/> </controlSources> <settings> <!-- Parameter name for users' Sitecore domain --> <setting name="UsersField.DomainParameterName" value="Domain" /> </settings> </sitecore> </configuration>
I then created a new subclass of Sitecore.Shell.Applications.ContentEditor.MultilistEx — I ascertained this to be the class used by the Multilist field in Sitecore by looking at /sitecore/system/Field types/List Types/Multilist field type in the core database:
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Security;
using System.Web.UI;
using Sitecore.Configuration;
using Sitecore.Diagnostics;
using Sitecore.Globalization;
using Sitecore.Resources;
using Sitecore.Security.Accounts;
using Sitecore.Shell.Applications.ContentEditor;
using Sitecore.Text;
using Sitecore.Sandbox.Shell.Applications.ContentEditor.Base;
namespace Sitecore.Sandbox.Shell.Applications.ContentEditor
{
public class UsersMultilist : MultilistEx
{
private IUsersField _UsersField;
private IUsersField UsersField
{
get
{
if (_UsersField == null)
{
_UsersField = CreateNewUsersField();
}
return _UsersField;
}
}
public UsersMultilist()
{
}
protected override void DoRender(HtmlTextWriter output)
{
Assert.ArgumentNotNull(output, "output");
SetIDProperty();
string disabledAttribute = string.Empty;
if (ReadOnly)
{
disabledAttribute = " disabled=\"disabled\"";
}
output.Write(string.Format("<input id=\"{0}_Value\" type=\"hidden\" value=\"{1}\" />", ID, StringUtil.EscapeQuote(Value)));
output.Write(string.Format("<table{0}>", GetControlAttributes()));
output.Write("<tr>");
output.Write(string.Format("<td class=\"scContentControlMultilistCaption\" width=\"50%\">{0}</td>", GetAllLabel()));
output.Write(string.Format("<td width=\"20\">{0}</td>", Images.GetSpacer(20, 1)));
output.Write(string.Format("<td class=\"scContentControlMultilistCaption\" width=\"50%\">{0}</td>", GetSelectedLabel()));
output.Write(string.Format("<td width=\"20\">{0}</td>", Images.GetSpacer(20, 1), "</td>"));
output.Write("</tr>");
output.Write("<tr>");
output.Write("<td valign=\"top\" height=\"100%\">");
output.Write(string.Format("<select id=\"{0}_unselected\" class=\"scContentControlMultilistBox\" multiple=\"multiple\"{1} size=\"10\" ondblclick=\"javascript:scContent.multilistMoveRight('{2}')\" onchange=\"javascript:document.getElementById('{3}_all_help').innerHTML=this.selectedIndex>=0?this.options[this.selectedIndex].innerHTML:''\">", ID, disabledAttribute, ID, ID));
IEnumerable<User> unselectedUsers = GetUnselectedUsers();
foreach (User unselectedUser in unselectedUsers)
{
output.Write(string.Format("<option value=\"{0}\">{1}</option>", GetProviderUserKey(unselectedUser), unselectedUser.Profile.UserName));
}
output.Write("</select>");
output.Write("</td>");
output.Write("<td valign=\"top\">");
RenderButton(output, "Core/16x16/arrow_blue_right.png", string.Format("javascript:scContent.multilistMoveRight('{0}')", ID));
output.Write("<br />");
RenderButton(output, "Core/16x16/arrow_blue_left.png", string.Format("javascript:scContent.multilistMoveLeft('{0}')", ID));
output.Write("</td>");
output.Write("<td valign=\"top\" height=\"100%\">");
output.Write(string.Format("<select id=\"{0}_selected\" class=\"scContentControlMultilistBox\" multiple=\"multiple\"{1} size=\"10\" ondblclick=\"javascript:scContent.multilistMoveLeft('{2}')\" onchange=\"javascript:document.getElementById('{3}_selected_help').innerHTML=this.selectedIndex>=0?this.options[this.selectedIndex].innerHTML:''\">", ID, disabledAttribute, ID, ID));
IEnumerable<User> selectedUsers = GetSelectedUsers();
foreach (User selectedUser in selectedUsers)
{
output.Write(string.Format("<option value=\"{0}\">{1}</option>", GetProviderUserKey(selectedUser), selectedUser.Profile.UserName));
}
output.Write("</select>");
output.Write("</td>");
output.Write("<td valign=\"top\">");
RenderButton(output, "Core/16x16/arrow_blue_up.png", string.Format("javascript:scContent.multilistMoveUp('{0}')", ID));
output.Write("<br />");
RenderButton(output, "Core/16x16/arrow_blue_down.png", string.Format("javascript:scContent.multilistMoveDown('{0}')", ID));
output.Write("</td>");
output.Write("</tr>");
output.Write("<tr>");
output.Write("<td valign=\"top\">");
output.Write(string.Format("<div style=\"border:1px solid #999999;font:8pt tahoma;padding:2px;margin:4px 0px 4px 0px;height:14px\" id=\"{0}_all_help\"></div>", ID));
output.Write("</td>");
output.Write("<td></td>");
output.Write("<td valign=\"top\">");
output.Write(string.Format("<div style=\"border:1px solid #999999;font:8pt tahoma;padding:2px;margin:4px 0px 4px 0px;height:14px\" id=\"{0}_selected_help\"></div>", ID));
output.Write("</td>");
output.Write("<td></td>");
output.Write("</tr>");
output.Write("</table>");
}
protected void SetIDProperty()
{
ServerProperties["ID"] = ID;
}
protected static string GetAllLabel()
{
return GetLabel("All");
}
protected static string GetSelectedLabel()
{
return GetLabel("Selected");
}
protected static string GetLabel(string key)
{
return Translate.Text(key);
}
protected IEnumerable<User> GetSelectedUsers()
{
return UsersField.GetSelectedUsers();
}
protected IEnumerable<User> GetUnselectedUsers()
{
return UsersField.GetUnselectedUsers();
}
protected string GetProviderUserKey(User user)
{
return UsersField.GetProviderUserKey(user);
}
// Method "borrowed" from MultilistEx control
protected void RenderButton(HtmlTextWriter output, string icon, string click)
{
Assert.ArgumentNotNull(output, "output");
Assert.ArgumentNotNull(icon, "icon");
Assert.ArgumentNotNull(click, "click");
ImageBuilder builder = new ImageBuilder
{
Src = icon,
Width = 0x10,
Height = 0x10,
Margin = "2px"
};
if (!ReadOnly)
{
builder.OnClick = click;
}
output.Write(builder.ToString());
}
private IUsersField CreateNewUsersField()
{
return ContentEditor.UsersField.CreateNewUsersField(Source, Value);
}
}
}
Overriding the DoRender() method paved the way for me to insert my custom Multilist options — options containg Sitecore users. Selected users will be saved as a pipe delimitered list of ASP.NET Membership UserIDs.
As I had done for my custom Single-Line Text above, I defined my custom Multilist field in the core database:
In the master database, I added a new field to my template using this new field type:
Now, let’s take this custom field for a test drive.
I went back to my item and saw that all Sitecore users were available for selection in my new field:
Facetiously imagine that a project manager has just run up to you anxiously lamenting — while breathing rapidly and sweating copiously — we cannot show users in the sitecore domain in our field, only those within the extranet domain — to do so will taint our credibility with our clients as Sitecore experts :). You then put on your superhero cape and proudly proclaim “rest assured, we’ve already baked this domain filtering functionality into our custom Multilist field!”:
I saved my template and navigated back to my item:
I selected a couple of users and saved:
I see their Membership UserIDs are being saved as designed:
My intent on creating the above custom fields was to showcase that the option to create your own fields exists in Sitecore. Why not have yourself a field day by creating your own? 🙂
Get Your House in Order: Create Your Own Subitems Sorting Comparer
This morning, I was fishing around in the core database of my local instance to research my next article — yeah, I know it’s Christmas morning and I should be resting, but unwrapping hidden gems in Sitecore.Kernel beats opening up presents anyday — and discovered that one can easily add his/her own subitems sorting comparer.
After opening up .NET reflector and using Sitecore.Data.Comparers.UpdatedComparer as a model, I created the following Comparer within minutes:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Data.Comparers;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
namespace Sitecore.Sandbox.Data.Comparers
{
public class ItemNameLengthComparer : ExtractedKeysComparer
{
protected override int CompareKeys(IKey keyOne, IKey keyTwo)
{
Assert.ArgumentNotNull(keyOne, "keyOne");
Assert.ArgumentNotNull(keyTwo, "keyTwo");
return IntegerCompareTo(keyOne.Key, keyTwo.Key);
}
protected override int DoCompare(Item itemOne, Item itemTwo)
{
return IntegerCompareTo(itemOne.Name.Length, itemTwo.Name.Length);
}
private static int IntegerCompareTo(object itemOneNameLength, object itemTwoNameLength)
{
return IntegerCompareTo((int)itemOneNameLength, (int)itemTwoNameLength);
}
private static int IntegerCompareTo(int itemOneNameLength, int itemTwoNameLength)
{
return itemOneNameLength.CompareTo(itemTwoNameLength);
}
public override IKey ExtractKey(Item item)
{
Assert.ArgumentNotNull(item, "item");
return new KeyObj
{
Item = item,
Key = item.Name.Length,
Sortorder = item.Appearance.Sortorder
};
}
}
}
All we have to do is override and add our own custom logic to the DoCompare(), CompareKeys() and ExtractKey() methods defined in the ExtractedKeysComparer base class.
The above Comparer will sort items based on the length of their names — items with shorter names will appear before their siblings with longer names.
Next, I created a new Child Sorting item in my master database — yes, I did say the master database since I learned the hard way (my sorting option wasn’t appearing) that these sorting comparers are to be defined in each database where they are used — for my Comparer:
Let’s see how this Comparer fares in the wild.
I first created a handful of test items with different item name lengths in a new parent folder under my Home node:
I then opened up the Subitems Sorting dialog and saw my new subitems sorting option in the ‘Sorting’ dropdown:
Nervously, I selected my new subitems sorting option and clicked ‘OK’:
I then wiped my brow and exhaled with relief after seeing that it had worked as I intended:
The above is further evidence of how customizable the Sitecore client truly is. Remember, the Sitecore client was built using the same API/technologies we use each and everyday to build websites within it — thus empowering us to extend the client where we see fit.
Happy Holidays! 🙂
Custom Sitecore Rich Text Editor Button: Inserting Dynamic Content
Last Thursday, I stumbled upon an article discussing how to create and add a custom button to the Rich Text Editor in Sitecore. This article referenced an article written by Mark Stiles — his article set the foundation for me to do this very thing last Spring for a client.
Unlike the two articles above, I had to create two different buttons to insert dynamic content — special html containing references to other items in the Sitecore content tree via Item IDs — which I would ‘fix’ via a RenderField pipeline when a user would visit the page containing this special html. I had modeled my code around the ‘Insert Link’ button by delegating to a helper class to ‘expand’ my dynamic content as the LinkManager class does for Sitecore links.
The unfortunate thing is I cannot show you that code – it’s proprietary code owned by a previous employer.
Instead, I decided to build something similar to illustrate how I did this. I will insert special html that will ultimately transform into jQuery UI dialogs.
First, I needed to create items that represent dialog boxes. I created a new template with two fields — one field containing the dialog box’s heading and the other field containing copy that will go inside of the dialog box:
I then created three dialog box items with content that we will use later on when testing:
With the help of xml defined in /sitecore/shell/Controls/Rich Text Editor/InsertLink/InsertLink.xml and /sitecore/shell/Controls/Rich Text Editor/InsertImage/InsertImage.xml, I created my new xml control definition in a file named /sitecore/shell/Controls/Rich Text Editor/InsertDialog/InsertDialog.xml:
<?xml version="1.0" encoding="utf-8" ?>
<control xmlns:def="Definition" xmlns="http://schemas.sitecore.net/Visual-Studio-Intellisense">
<RichText.InsertDialog>
<!-- Don't forget to set your icon 🙂 -->
<FormDialog Icon="Business/32x32/message.png" Header="Insert a Dialog" Text="Select the dialog content item you want to insert." OKButton="Insert Dialog">
<!-- js reference to my InsertDialog.js script. -->
<!-- For some strange reason, if the period within the script tag is not present, the dialog form won't work -->
<script Type="text/javascript" src="/sitecore/shell/Controls/Rich Text Editor/InsertDialog/InsertDialog.js">.</script>
<!-- Reference to my InsertDialogForm class -->
<CodeBeside Type="Sitecore.Sandbox.RichTextEditor.InsertDialog.InsertDialogForm,Sitecore.Sandbox" />
<!-- Root contains the ID of /sitecore/content/Dialog Content Items -->
<DataContext ID="DialogFolderDataContext" Root="{99B14D44-5A0F-43B6-988E-94197D73B348}" />
<GridPanel Width="100%" Height="100%" Style="table-layout:fixed">
<GridPanel Width="100%" Height="100%" Style="table-layout:fixed" Columns="3" GridPanel.Height="100%">
<Scrollbox Class="scScrollbox scFixSize" Width="100%" Height="100%" Background="white" Border="1px inset" Padding="0" GridPanel.Height="100%" GridPanel.Width="50%" GridPanel.Valign="top">
<TreeviewEx ID="DialogContentItems" DataContext="DialogFolderDataContext" Root="true" />
</Scrollbox>
</GridPanel>
</GridPanel>
</FormDialog>
</RichText.InsertDialog>
</control>
My dialog form will house one lonely TreeviewEx containing all dialog items in the /sitecore/content/Dialog Content Items folder I created above.
I then created the ‘CodeBeside’ DialogForm class to accompany the xml above:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Shell.Framework;
using Sitecore.Web;
using Sitecore.Web.UI.HtmlControls;
using Sitecore.Web.UI.Pages;
using Sitecore.Web.UI.Sheer;
using Sitecore.Web.UI.WebControls;
namespace Sitecore.Sandbox.RichTextEditor.InsertDialog
{
public class InsertDialogForm : DialogForm
{
protected DataContext DialogFolderDataContext;
protected TreeviewEx DialogContentItems;
protected string Mode
{
get
{
string mode = StringUtil.GetString(base.ServerProperties["Mode"]);
if (!string.IsNullOrEmpty(mode))
{
return mode;
}
return "shell";
}
set
{
Assert.ArgumentNotNull(value, "value");
base.ServerProperties["Mode"] = value;
}
}
protected override void OnLoad(EventArgs e)
{
Assert.ArgumentNotNull(e, "e");
base.OnLoad(e);
if (!Context.ClientPage.IsEvent)
{
Inialize();
}
}
private void Inialize()
{
SetMode();
SetDialogFolderDataContextFromQueryString();
}
private void SetMode()
{
Mode = WebUtil.GetQueryString("mo");
}
private void SetDialogFolderDataContextFromQueryString()
{
DialogFolderDataContext.GetFromQueryString();
}
protected override void OnOK(object sender, EventArgs args)
{
Assert.ArgumentNotNull(sender, "sender");
Assert.ArgumentNotNull(args, "args");
string selectedItemID = GetSelectedItemIDAsString();
if (string.IsNullOrEmpty(selectedItemID))
{
return;
}
string selectedItemPath = GetSelectedItemPath();
string javascriptArguments = string.Format("{0}, {1}", EscapeJavascriptString(selectedItemID), EscapeJavascriptString(selectedItemPath));
if (IsWebEditMode())
{
SheerResponse.SetDialogValue(javascriptArguments);
base.OnOK(sender, args);
}
else
{
string closeJavascript = string.Format("scClose({0})", javascriptArguments);
SheerResponse.Eval(closeJavascript);
}
}
private string GetSelectedItemIDAsString()
{
ID selectedID = GetSelectedItemID();
if (selectedID != ID.Null)
{
return selectedID.ToString();
}
return string.Empty;
}
private ID GetSelectedItemID()
{
Item selectedItem = GetSelectedItem();
if (selectedItem != null)
{
return selectedItem.ID;
}
return ID.Null;
}
private string GetSelectedItemPath()
{
Item selectedItem = GetSelectedItem();
if (selectedItem != null)
{
return selectedItem.Paths.FullPath;
}
return string.Empty;
}
private Item GetSelectedItem()
{
return DialogContentItems.GetSelectionItem();
}
private static string EscapeJavascriptString(string stringToEscape)
{
return StringUtil.EscapeJavascriptString(stringToEscape);
}
protected override void OnCancel(object sender, EventArgs args)
{
Assert.ArgumentNotNull(sender, "sender");
Assert.ArgumentNotNull(args, "args");
if (IsWebEditMode())
{
base.OnCancel(sender, args);
}
else
{
SheerResponse.Eval("scCancel()");
}
}
private bool IsWebEditMode()
{
return string.Equals(Mode, "webedit", StringComparison.InvariantCultureIgnoreCase);
}
}
}
This DialogForm basically sends a selected Item’s ID and path back to the client via the scClose() function defined below in a new file named /sitecore/shell/Controls/Rich Text Editor/InsertDialog/InsertDialog.js:
function GetDialogArguments() {
return getRadWindow().ClientParameters;
}
function getRadWindow() {
if (window.radWindow) {
return window.radWindow;
}
if (window.frameElement && window.frameElement.radWindow) {
return window.frameElement.radWindow;
}
return null;
}
var isRadWindow = true;
var radWindow = getRadWindow();
if (radWindow) {
if (window.dialogArguments) {
radWindow.Window = window;
}
}
function scClose(dialogContentItemId, dialogContentItemPath) {
// we're passing back an object holding data needed for inserting our special html into the RTE
var dialogInfo = {
dialogContentItemId: dialogContentItemId,
dialogContentItemPath: dialogContentItemPath
};
getRadWindow().close(dialogInfo);
}
function scCancel() {
getRadWindow().close();
}
if (window.focus && Prototype.Browser.Gecko) {
window.focus();
}
I then had to add a new javascript command in /sitecore/shell/Controls/Rich Text Editor/RichText Commands.js to open my dialog form and map this to a callback function — which I named scInsertDialog to follow the naming convention of other callbacks within this script file — to handle the dialogInfo javascript object above that is passed to it:
RadEditorCommandList["InsertDialog"] = function(commandName, editor, args) {
scEditor = editor;
editor.showExternalDialog(
"/sitecore/shell/default.aspx?xmlcontrol=RichText.InsertDialog&la=" + scLanguage,
null, //argument
500,
400,
scInsertDialog, //callback
null, // callback args
"Insert Dialog",
true, //modal
Telerik.Web.UI.WindowBehaviors.Close, // behaviors
false, //showStatusBar
false //showTitleBar
);
};
function scInsertDialog(sender, dialogInfo) {
if (!dialogInfo) {
return;
}
// build our special html to insert into the RTE
var placeholderHtml = "<hr class=\"dialog-placeholder\" style=\"width: 100px; display: inline-block; height: 20px;border: blue 4px solid;\""
+ "data-dialogContentItemId=\"" + dialogInfo.dialogContentItemId +"\" "
+ "title=\"Dialog Content Path: " + dialogInfo.dialogContentItemPath + "\" />";
scEditor.pasteHtml(placeholderHtml, "DocumentManager");
}
My callback function builds the special html that will be inserted into the Rich Text Editor. I decided to use a <hr /> tag to keep my html code self-closing — it’s much easier to use a self-closing html tag when doing this, since you don’t have to check whether you’re inserting more special html into preexisting special html. I also did this for the sake of brevity.
I am using the title attribute in my special html in order to assist content authors in knowing which dialog items they’ve inserted into rich text fields. When a dialog blue box is hovered over, a tooltip containing the dialog item’s content path will display.
I’m not completely satisfied with building my special html in this javascript file. It probably should be moved into C# somewhere to prevent the ease of changing it — someone could easily just change it without code compilation, and break how it’s rendered to users. We need this html to be a certain way when ‘fixing’ it in our RenderField pipeline defined later in this article.
I then went into the core database and created a new Rich Text Editor profile under /sitecore/system/Settings/Html Editor Profiles:
Next, I created a new Html Editor Button (template: /sitecore/templates/System/Html Editor Profiles/Html Editor Button) using the __Html Editor Button master — wow, I never thought I would use the word master again when talking about Sitecore stuff 🙂 — and set its click and icon fields:
Now, we need to ‘fix’ our special html by converting it into presentable html to the user. I do this using the following RenderField pipeline:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Sitecore;
using Sitecore.Data.Items;
using Sitecore.Diagnostics;
using Sitecore.Links;
using Sitecore.Pipelines.RenderField;
using Sitecore.Xml.Xsl;
using HtmlAgilityPack;
namespace Sitecore.Sandbox.Pipelines.RenderField
{
public class ExpandDialogContent
{
public void Process(RenderFieldArgs renderFieldArgs)
{
if (ShouldFieldBeProcessed(renderFieldArgs))
{
ExpandDialogContentTags(renderFieldArgs);
}
}
private bool ShouldFieldBeProcessed(RenderFieldArgs renderFieldArgs)
{
return renderFieldArgs.FieldTypeKey.ToLower() == "rich text";
}
private void ExpandDialogContentTags(RenderFieldArgs renderFieldArgs)
{
HtmlNode documentNode = GetHtmlDocumentNode(renderFieldArgs.Result.FirstPart);
HtmlNodeCollection dialogs = documentNode.SelectNodes("//hr[@class='dialog-placeholder']");
foreach (HtmlNode dialogPlaceholder in dialogs)
{
HtmlNode dialog = CreateDialogHtmlNode(dialogPlaceholder);
if (dialog != null)
{
dialogPlaceholder.ParentNode.ReplaceChild(dialog, dialogPlaceholder);
}
else
{
dialogPlaceholder.ParentNode.RemoveChild(dialogPlaceholder);
}
}
renderFieldArgs.Result.FirstPart = documentNode.InnerHtml;
}
private HtmlNode GetHtmlDocumentNode(string html)
{
HtmlDocument htmlDocument = CreateNewHtmlDocument(html);
return htmlDocument.DocumentNode;
}
private HtmlDocument CreateNewHtmlDocument(string html)
{
HtmlDocument htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(html);
return htmlDocument;
}
private HtmlNode CreateDialogHtmlNode(HtmlNode dialogPlaceholder)
{
string dialogContentItemId = dialogPlaceholder.Attributes["data-dialogContentItemId"].Value;
Item dialogContentItem = TryGetItem(dialogContentItemId);
if(dialogContentItem != null)
{
string heading = dialogContentItem["Dialog Heading"];
string content = dialogContentItem["Dialog Content"];
return CreateDialogHtmlNode(dialogPlaceholder.OwnerDocument, heading, content);
}
return null;
}
private Item TryGetItem(string id)
{
try
{
return Context.Database.Items[id];
}
catch (Exception ex)
{
Log.Error(this.ToString(), ex, this);
}
return null;
}
private static HtmlNode CreateDialogHtmlNode(HtmlDocument htmlDocument, string heading, string content)
{
if (string.IsNullOrEmpty(content))
{
return null;
}
HtmlNode dialog = htmlDocument.CreateElement("div");
dialog.Attributes.Add("class", "dialog");
dialog.Attributes.Add("title", heading);
dialog.InnerHtml = content;
return dialog;
}
}
}
The above uses Html Agility Pack for finding all instances of my special <hr /> tags and creates new html that my jQuery UI dialog code expects. If you’re not using Html Agility Pack, I strongly recommend checking it out — it has saved my hide on numerous occasions.
I then inject this RenderField pipeline betwixt others via a new patch include file named /App_Config/Include/ExpandDialogContent.config:
<?xml version="1.0" encoding="utf-8"?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <renderField> <processor type="Sitecore.Sandbox.Pipelines.RenderField.ExpandDialogContent, Sitecore.Sandbox" patch:after="processor[@type='Sitecore.Pipelines.RenderField.GetInternalLinkFieldValue, Sitecore.Kernel']" /> </renderField> </pipelines> </sitecore> </configuration>
Now, it’s time to see if all of my hard work above has paid off.
I set the rich text field on my Sample Item template to use my new Rich Text Editor profile:
Using the template above, I created a new test item, and opened its rich text field’s editor:
I then clicked my new ‘Insert Dialog’ button and saw Dialog items I could choose to insert:
Since I’m extremely excited, I decided to insert them all:
I then forgot which dialog was which — the three uniform blue boxes are throwing me off a bit — so I hovered over the first blue box and saw that it was the first dialog item:
I then snuck a look at the html inserted — it was formatted the way I expected:
I then saved my item, published and navigated to my test page. My RenderField pipeline fixed the special html as I expected:
I would like to point out that I had to add link and script tags to reference jQuery UI’s css and js files — including the jQuery library — and initialized my dialogs using the jQuery UI Dialog constructor. I have omitted this code.
This does seem like a lot of code to get something so simple to work.
However, it is worth the effort. Not only will you impress your boss and make your clients happy, you’ll also be dubbed the ‘cool kid’ at parties. You really will, I swear. 🙂
Until next time, have a Sitecoretastic day!

















































