SharePoint Related Fields

I had an interest request from a client I was working with this week. They wanted
me to create a list for their store tracking business, which consisted of a large
number of columns. No big deal! That is until my client indicated that they wanted
to associate notes with each column for each list item entered into the list.


image

The image attached to this post shows the standard SharePoint New and Edit Form
for a list item. Ignore the fact that this is SharePoint 2007, because my solution
works just as well in SharePoint 2010 and 2013. Notice that each field has a notes
link, which when clicked will display a text box for adding additional notes, as
shown.

You may be thinking “just add additional columns of multiple lines of text to the
list”, and I considered this approach, but my client wanted the ability to add a
new column in their list without having to remember to add an additional column
for the notes. Further, they wanted a way to associate the notes with the column
automatically.

My next instinct was to use custom fields and custom field controls, which turned
out to be the core of my solution. Custom fields and field controls can be a pain
at times, and not always behave as predicted, but fortunately my client only used
single line of text, yes/no, lookup, choice, and date-time columns, so I was able
to derive custom versions of these controls to provide the behavior I was looking
to achieve.

The standard SPField type, from which all custom fields derive, contains a property
called “RelatedField”. This related field contains the title of a related field
to current column. The SPField class also includes some event handlers for the added
and deleting events, which I used to automatically create notes fields whenever
a new column is added to the list.

Let’s start with one of my custom SPField classes, which derives from the SPFieldText
to add custom logic to the stock single-line-of-text field type:

 
    public class CustomFieldText : SPFieldText
    {
        #region Fields

        private readonly SPFieldCollection _fields;

        #endregion Fields

        #region Construction

        public CustomFieldText(SPFieldCollection fields, string fieldName)
            : base(fields, fieldName)
        {
            _fields = fields;
        }

        public CustomFieldText(SPFieldCollection fields, string typeName, string displayName)
            : base(fields, typeName, displayName)
        {
            _fields = fields;
        }

        #endregion Construction

        #region Properties

        public override BaseFieldControl FieldRenderingControl
        {
            [SharePointPermission(SecurityAction.LinkDemand, ObjectModel = true)]
            get
            {
                BaseFieldControl fieldControl = new CustomFieldTextControl();
                fieldControl.FieldName = InternalName;
                return fieldControl;
            }
        }

        #endregion Properties

        #region Methods

        public override void OnAdded(SPAddFieldOptions op)
        {
            CustomFieldHelper.CreateSlaveField(this);
        }

        public override void OnDeleting()
        {
            CustomFieldHelper.DeleteSlaveField(_fields, this);
        }

        #endregion Methods
    }

Looking at the previous class, there really isn’t much to my implementation. My
class implements the standard constructors for an SPField derived class, overrides
the FieldRenderingControl property because I wish to use my own, and overrides the
OnAdded and OnDeleting events, which enables me to detect when a column of my field
type is created or deleted. The interesting code logic exists in my helper class,
as follows:

 
    static class CustomFieldHelper
    {
        private const string RenderFieldSuffix = "_Shaddow";

        public static void CreateSlaveField(SPField master)
        {
            if (null == master) throw new ArgumentNullException("master");
            // Create a shadow field to store the value we want to display
            // in list views.
            var list = master.ParentList;
            if (null == list) return;
            // We only need a shadow copy when associated with a list.
            var relatedFieldName = master.InternalName + RenderFieldSuffix;
            var relatedDisplayName = String.Format("{0} Notes", master.Title);
            var sb = new StringBuilder();
            sb.Append("<Field Type="Note" ReadOnly="TRUE" ");
            sb.AppendFormat("Name="{0}" ", relatedFieldName);
            sb.AppendFormat("DisplayName="{0}" ", relatedFieldName);
            sb.Append("Sortable="TRUE" Filterable="TRUE" ");
            sb.Append("EnableLookup="FALSE" SourceID="http://schemas.microsoft.com/sharepoint/v3">");
            sb.AppendFormat("<FieldRefs><FieldRef Name="{0}" /></FieldRefs>", master.InternalName);
            sb.Append("<DisplayPattern><HTML><Column HTMLEncode="FALSE"/></HTML></DisplayPattern>");
            sb.Append("</Field>");
            list.Fields.AddFieldAsXml(sb.ToString());
            var field = list.Fields[relatedFieldName];
            field.Title = relatedDisplayName;
            field.RelatedField = master.Title;
            field.Update(true);
        }

        public static void DeleteSlaveField(SPFieldCollection fields, SPField master)
        {
            if (null == fields) throw new ArgumentNullException("fields");
            if (null == master) throw new ArgumentNullException("master");
            var relatedFieldInternalName = master.InternalName + RenderFieldSuffix;
            if (!fields.ContainsField(relatedFieldInternalName)) return;
            var field = fields.GetFieldByInternalName(relatedFieldInternalName);
            field.ReadOnlyField = false;
            field.Hidden = false;
            field.Update();
            fields.Delete(relatedFieldInternalName);
        }

        public static void SaveValueToSlave(SPListItem item, string value, SPField master, bool callUpdate)
        {
            if (null == item) throw new ArgumentNullException("item");
            if (null == master) throw new ArgumentNullException("master");
            if (null == value) return;
            var relatedFieldInternalName = master.InternalName + RenderFieldSuffix;
            var list = item.ParentList;
            var field = !list.Fields.ContainsField(relatedFieldInternalName) ? 
                list.Fields.Cast<SPField>().FirstOrDefault(f => f.RelatedField == master.Title) : 
                list.Fields.GetFieldByInternalName(relatedFieldInternalName);
            if (null == field) return;
            item[field.Id] = value;
            if (callUpdate) item.SystemUpdate();
        }

        public static string GetValueFromSlave(SPListItem item, SPField master)
        {
            if (null == item) throw new ArgumentNullException("item");
            if (null == master) throw new ArgumentNullException("master");
            var relatedFieldInternalName = master.InternalName + RenderFieldSuffix;
            var list = item.ParentList;
            var field = !list.Fields.ContainsField(relatedFieldInternalName) ?
                list.Fields.Cast<SPField>().FirstOrDefault(f => f.RelatedField == master.Title) :
                list.Fields.GetFieldByInternalName(relatedFieldInternalName);
            if (null == field) return "";
            var obj = item[field.Id];
            return null == obj ? "" : obj.ToString();
        }

        public static Control GetNotesMarkUp(out TextBox notesCtrl, SPField field)
        {
            if (null == field) throw new ArgumentNullException("field");
            var ph = new PlaceHolder();
            // Does shaddow field have data?
            var ident = (null != SPContext.Current.ListItem)
                            ? GetValueFromSlave(SPContext.Current.ListItem, field)
                            : null;
            var jsStr = String.Format("document.getElementById('{0}').style.display='block';", field.Id);
            ph.Controls.Add(!String.IsNullOrEmpty(ident)
                                ? new LiteralControl(String.Format(
                                    "<a href='#' onclick="{0}"><b>Notes</b> •</a>", jsStr))
                                : new LiteralControl(String.Format("<a href='#' onclick="{0}">Notes</a>", jsStr)));
            ph.Controls.Add(new LiteralControl(String.Format("<div id='{0}' style='display:none;'>", field.Id)));
            var table = new HtmlTable { Width = "100%" };
            ph.Controls.Add(table);
            ph.Controls.Add(new LiteralControl("</div>"));
            var row = new HtmlTableRow();
            var header = new HtmlTableCell();
            var headerText = new LiteralControl("<span class='ms-formlabel'><H3 class='ms-standardheader'>Enter notes below</H3></span>");
            header.Controls.Add(headerText);
            row.Controls.Add(header);
            table.Controls.Add(row);
            row = new HtmlTableRow();
            var cell = new HtmlTableCell();
            notesCtrl = new TextBox { TextMode = TextBoxMode.MultiLine, Rows = 6, Width = new Unit(100, UnitType.Percentage) };
            cell.Controls.Add(notesCtrl);
            row.Controls.Add(cell);
            table.Controls.Add(row);
            return ph;
        }
    }

In case you’re wondering why I didn’t just include my helper code in my custom field
class it’s because I created several custom field classes for my client and wanted
to reuse the same code. Since I’m inheriting from SharePoint’s classes I cannot
provide my own base class either, so a static helper class seemed like an easy approach.

The CreateSlaveField method is the most interesting of the previously show class.
This method creates a new Note field and adds it to the list that the master field
associates, it then populates the RelatedField property so I can find the association
later.

The notes field is only created if the master field is associated with a list. In
the case when a site owner creates a site column in a web, my method exits without
creating the slave field, because it’s only pertinent in lists. Since list columns
added from the web site columns gallery are new instances of the same field type,
the OnAdded method is called again when the new column adds to a list and this time
my method creates the slave column.

The DeleteSlaveField method removes the notes slave field when the master field
is removed from a list instance – this is just good house keeping.

When creating the slave field I set it as read only, this is so intrigued users
of the site cannot use the notes field for any other purpose than adding notes via
my custom field control, which brings me to my next class:

    public class CustomFieldTextControl : TextField
    {
        #region Fields

        private TextBox _notesCtrl;

        #endregion Fields

        #region Methods

        protected override void OnInit(EventArgs e)
        {
            CanCacheRenderedFieldValue = false;
            base.OnInit(e);
        }

        protected override void CreateChildControls()
        {
            if (IsFieldValueCached)
            {
                base.CreateChildControls();
                return;
            }
            if (null == Field) return;
            base.CreateChildControls();
            // Add the notes if in edit mode.
            if (ControlMode == SPControlMode.Edit || ControlMode == SPControlMode.New)
                base.Controls.Add(CustomFieldHelper.GetNotesMarkUp(out _notesCtrl, Field));
            // Update the controls with the current value stored.
            if (null != _notesCtrl && null != Field)
                _notesCtrl.Text = CustomFieldHelper.GetValueFromSlave(SPContext.Current.ListItem, Field);
        }

        public override void UpdateFieldValueInItem()
        {
            Page.Validate();
            if (!Page.IsValid) return;
            base.UpdateFieldValueInItem();
            // do actions after save
            if (null == Field) return;
            CustomFieldHelper.SaveValueToSlave(SPContext.Current.ListItem, _notesCtrl.Text.Trim(), Field, false);
        }

        #endregion Methods
    }

The previous and last class is my custom field control, which does the work of rendering
data from my custom field class. Again, this is a lightweight class, leaving the
heavy lifting to the field helper class.

The CreateChildControls method is called for any ASP.NET UI class (which custom
field controls ultimately derive) to load an child control instances. In this method,
I check that we’re not using a cached version of the control and that we’re in a
new or edit form, since my client didn’t want notes to appear in display only views.
I then inject HTML for the display of the notes text box, which is populated from
the contents of the associated slave control.

The overridden method UpdateFieldValueInItem ensures that the slave field in the
list item receives any text changes applied to the notes field when saving the list
item in the new/edit form.

That’s about it, except for the fldtypes_custom.xml file, which I deploy to the
%HIVE%TemplatesXML folder to register my custom field type(s):

 
<FieldTypes>
  <FieldType>
    <Field Name="CAMLRendering">TRUE</Field>
    <Field Name="TypeName">CustomFieldText</Field>
    <Field Name="TypeDisplayName">CustomFieldText</Field>
    <Field Name="TypeShortDescription">Custom Single Line of Text (with Notes).</Field>
    <Field Name="ParentType">Text</Field>
    <Field Name="UserCreatable">TRUE</Field>
    <Field Name="AllowBaseTypeRendering">TRUE</Field>
    <Field Name="FieldTypeClass">CustomSharePoint.CustomFieldText, CustomSharePoint, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cd2be6bc9c119b34</Field>
  </FieldType>
</FieldTypes>