Last week we saw how to hide specific field controls or how to display them as read-only even on the edit form. In the current post I introduce a simple technique one can apply to create custom validation for SharePoint forms without InfoPath.
First, what are the main problems with the out of the box SharePoint form validation capability?
As written in the first post of this series about custom list field iterators, SharePoint 2010 made a large step to the right direction compared to its predecessor, WSS 3.0. One can define field level and form level validation rules using a syntax similar to the one used with calculated columns, based on values of other fields of the current item.
However, you can define only a single validation rule for your form having a single validation error message, and a single validation rule and error message for the fields. Furthermore, there is another serious limitation, namely one can validate only the basic field types (like text, date, numeric) and the same is true to the fields one can refer to in the validation rule.
It means, you are out of luck if you have to work with validation of lookup or user fields, managed metadata or even your custom field types.
In the validation rule you can refer to the fields of the current item, you cannot build validation rules based on field values of other, related items, custom user properties, not to mention accessing other systems, like CRM, 3rd party web services, etc.
Moreover, the validation error messages are static text, one can not built them up dynamically based on field values, for example.
I found that in real world applications customers need usually much more validation power. All of these inflexibilities listed above lead me to create some kind of custom solution for validation. You can read another approach built on custom field types here, but in this post I illustrate the list field iterator based solution.
As you might know the BaseFieldControl class, the base class of all field controls implements the IValidator interface. In practice it means, you can use these field controls similar to standard ASP.NET validator controls.
But before working with them, we first have to get a reference to the field controls. So we extend our BaseListFieldIterator class – introduced in the previous part – with the GetFieldControlByName method. There might be multiple forms on the page, so just to be sure that the field control is within our list field iterator, we call the GetIteratorByFieldControl method and compare the ClientID values.
The SetValidationError method is to set the validation error for the specified field.
- protected BaseFieldControl GetFieldControlByName(String fieldNameToFind)
- {
- foreach (Control control in _formContext.FieldControlCollection)
- {
- if (control is BaseFieldControl)
- {
- BaseFieldControl baseField = (BaseFieldControl)control;
- String fieldName = baseField.FieldName;
- if ((fieldName == fieldNameToFind) &&
- (GetIteratorByFieldControl(baseField).ClientID == ClientID))
- {
- return baseField;
- }
- }
- }
- return null;
- }
- protected Microsoft.SharePoint.WebControls.ListFieldIterator GetIteratorByFieldControl(BaseFieldControl fieldControl)
- {
- return (Microsoft.SharePoint.WebControls.ListFieldIterator)fieldControl.Parent.Parent.Parent.Parent.Parent;
- }
- protected void SetValidationError(String fieldName, String errorMessage)
- {
- BaseFieldControl fieldControl = GetFieldControlByName(fieldName);
- fieldControl.ErrorMessage = errorMessage;
- fieldControl.IsValid = false;
- }
Next we have to add the following code to our custom ListFieldIterator class.
- protected override void OnLoad(EventArgs e)
- {
- if (Page.IsPostBack)
- {
- Page.Validate();
- this.Validate();
- }
- }
- public void Validate()
- {
- if (base.ControlMode != SPControlMode.Display)
- {
- // here comes the validation logic
- }
- }
We will extend the Validate method to fulfill our fictitious business needs.
The validation messages are stored as constants:
- public static class ValidationMessages
- {
- public static readonly String TitleNotUnique = "Task title should be unique";
- public static readonly String UserBusy = "This user already has another task for this time";
- public static readonly String NoDateForAssignement = "A user can be assigned only if the start and due dates are specified";
- public static readonly String PredecessorsNotCompleted = "At least one of the predecessors is not yet completed";
- public static readonly String PredecessorSelfReference = "A task cannot be its own predecessor";
- public static readonly String DueDateEarlierThanStartDate = "Due date should not be earlier than start date ";
- public static readonly String CompletedTaskPercentage = "For a completed task the % complete should be 100";
- }
Despite of the constant values used in the sample, it is easy to create dynamic messages as well. For example, you can define a message like "Task title ‘{0}’ is not unique", then substitute the current value when setting the validation error.
The first rule is to allow only unique task titles.
- TextField titleField = GetFieldControlByName(SampleTaskListFields.Title) as TextField;
- if (titleField != null)
- {
- String title = titleField.Value as String;
- if (!String.IsNullOrEmpty(title))
- {
- if (ItemWithSameTitleExists(title))
- {
- SetValidationError(SampleTaskListFields.Title, ValidationMessages.TitleNotUnique);
- }
- }
- }
We check the uniqueness through a CAML query, and use the GetFieldRefs helper method to build the value of the ViewFields property of the SPQuery instance.
- protected bool ItemWithSameTitleExists(string title)
- {
- SPQuery query = new SPQuery();
- query.ViewFields = GetFieldRefs(SampleTaskListFields.Title);
- // we should not check the ID in this case, as altering the title is not allowed
- // title will be validated only on new item creation
- // if title would be editable for existing items, then we should check
- // whether the ID is not the one of the current (edited) item
- // note, that CAML Eq for a Text field type is case insensitive
- query.Query = String.Format("<Where><Eq><FieldRef Name='{0}'/><Value Type='Text'>{1}</Value></Eq></Where>",
- SampleTaskListFields.Title, title);
- bool result = List.GetItems(query).Count > 0;
- return result;
- }
- private String GetFieldRefs(params String[] fieldNames)
- {
- String fieldRefs = String.Concat(fieldNames.ToList().
- ConvertAll(fieldName => String.Format("<FieldRef Name='{0}'/>", fieldName)));
- return fieldRefs;
- }
The next image shows the validator in action.
The comparison is case insensitive.
Yes, you are right it is nothing more than a custom implementation for the built-in Enforce unique values feature, but this one does not require indexed columns, and you have the chance to handle issues like leading / trailing spaces. For example, if you enable Enforce unique values, it won’t prohibit users entering ‘ Sample task 1 ‘ without any difficulties. If you don’t like this, you can trim the title parameter value and/or use Contains instead of Eq in the CAML query.
After this basic validation, let’s see a more tricky one. This one can be solved using standard field validation, although in this case it is more complicated “thanks” to the read-only Start date field when the task is not in the “Not Started” status.
- DateTimeField startDateField = GetFieldControlByName(SampleTaskListFields.StartDate) as DateTimeField;
- DateTimeField dueDateField = GetFieldControlByName(SampleTaskListFields.DueDate) as DateTimeField;
- // if the task is not in 'Not Started' status, then we display the start date
- // as read-only and value would be null
- // in this case we should get the real value, stored in the item
- DateTime? startDateValue = (startDateField.ControlMode == SPControlMode.Display) ? (DateTime?)Item[SampleTaskListFields.StartDate] : startDateField.Value as DateTime?;
- DateTime? dueDateValue = dueDateField.Value as DateTime?;
- if ((startDateValue.HasValue) && (dueDateValue.HasValue))
- {
- if (dueDateValue.Value < startDateValue.Value)
- {
- SetValidationError(SampleTaskListFields.DueDate, ValidationMessages.DueDateEarlierThanStartDate);
- }
- }
The illustration of the validation error:
This validation is again a simple one. We require 100% for % Complete when Status is Completed.
- DropDownChoiceField statusField = GetFieldControlByName(SampleTaskListFields.Status) as DropDownChoiceField;
- NumberField percComplField = GetFieldControlByName(SampleTaskListFields.PercentComplete) as NumberField;
- bool checkForPredecessorStatus = false;
- if ((statusField != null) && (percComplField != null))
- {
- String statusFieldValue = statusField.Value as String;
- Double? percComplValue = percComplField.Value as Double?;
- if (statusFieldValue == TaskStates.Completed)
- {
- checkForPredecessorStatus = true;
- if ((!percComplValue.HasValue) || (percComplValue.Value != 1))
- {
- SetValidationError(SampleTaskListFields.PercentComplete, ValidationMessages.CompletedTaskPercentage);
- }
- }
- }
The screenshot of the validation:
Before you leave the post unread thinking this technique provides no additional value for validation, let’s see some more advanced example.
Using the standard Tasks list you can set an existing task as its own predecessor. That is not very nice, and in this case I found no simple OOB SharePoint tool to prohibit that.
Using this technique, you can achieve the result as shown below:
- MultipleLookupField predecessorsField = GetFieldControlByName(SampleTaskListFields.Predecessors) as MultipleLookupField;
- if (predecessorsField != null)
- {
- SPFieldLookupValueCollection predecessorsValue = predecessorsField.Value as SPFieldLookupValueCollection;
- if (predecessorsValue != null)
- {
- // a task can reference itself only in edit mode
- if (ControlMode == SPControlMode.Edit)
- {
- if (!predecessorsValue.TrueForAll(predecessor => predecessor.LookupId != ItemId))
- {
- SetValidationError(SampleTaskListFields.Predecessors, ValidationMessages.PredecessorSelfReference);
- }
- }
- if (checkForPredecessorStatus)
- {
- if (!IsAllTasksCompleted(predecessorsValue.ConvertAll(predecessor => predecessor.LookupId)))
- {
- SetValidationError(SampleTaskListFields.Status, ValidationMessages.PredecessorsNotCompleted);
- }
- }
- }
- }
And in action:
The code above contains a check of predecessors when the task is to be set Completed. (The value of checkForPredecessorStatus computed earlier when validating % Complete.) We don’t want to allow that if there is at least on uncompleted predecessor. For the check we use the IsAllTasksCompleted method, passing the task ID of all of the predecessors as parameter.
- private bool IsAllTasksCompleted(List<int> taskIds)
- {
- SPQuery query = new SPQuery();
- query.ViewFields = GetFieldRefs(
- SampleTaskListFields.Id,
- SampleTaskListFields.Status);
- query.Query = String.Format("<Where><Neq><FieldRef Name='{0}'/><Value Type='Text'>{1}</Value></Neq></Where>",
- SampleTaskListFields.Status, TaskStates.Completed);
- bool result = true;
- SPListItemCollection tasksNotCompleted = List.GetItems(query);
- foreach (SPListItem task in tasksNotCompleted)
- {
- if (taskIds.Contains((int)task[SampleTaskListFields.Id]))
- {
- result = false;
- break;
- }
- }
- return result;
- }
The result of the validation is shown here:
Next, we want to allow to assign a user to the task if both start and due dates are set.
- UserField userField = GetFieldControlByName(SampleTaskListFields.AssignedTo) as UserField;
- if (userField != null)
- {
- String userFieldValue = userField.Value as String;
- if (!String.IsNullOrEmpty(userFieldValue))
- {
- if ((startDateValue.HasValue) && (dueDateValue.HasValue))
- {
- if (startDateValue.Value <= dueDateValue.Value)
- {
- SPFieldUserValue userValue = new SPFieldUserValue(Web, userFieldValue);
- int? taskId = (base.ControlMode == SPControlMode.Edit) ? ItemId : (int?)null;
- if ((userValue.LookupId != -1) && (UserIsBusy(userValue.LookupId, taskId, startDateValue.Value, dueDateValue.Value)))
- {
- SetValidationError(SampleTaskListFields.AssignedTo, ValidationMessages.UserBusy);
- }
- }
- }
- else
- {
- SetValidationError(SampleTaskListFields.AssignedTo, ValidationMessages.NoDateForAssignement);
- }
- }
- }
On the screenshot below, I “forgot” to set the start date, that is not allowed in this case.
Finally, using the method described in this post, we allow to assign the user for the task, if (s)he has no other assignment for that time interval. In this case we should ignore the task being edited when doing the validation.
- protected bool UserIsBusy(int userId, int? taskId, DateTime startDate, DateTime dueDate)
- {
- SPQuery query = new SPQuery();
- query.ViewFields = GetFieldRefs(
- SampleTaskListFields.Id,
- SampleTaskListFields.AssignedTo,
- SampleTaskListFields.StartDate,
- SampleTaskListFields.DueDate);
- // NOTE: you can add filter for Status Neq 'Completed' as well if you wish
- // I have not included that for the sake of simplicity
- if (taskId.HasValue)
- {
- // it is editing an existing task, so we should exclude the task itself
- query.Query = String.Format("<Where><And><And><Neq><FieldRef Name='{0}'/><Value Type='Integer'>{1}</Value></Neq><Eq><FieldRef Name='{2}' LookupId='TRUE' /><Value Type='Lookup'>{3}</Value></Eq></And>{4}</And></Where>",
- SampleTaskListFields.Id, taskId.Value,
- SampleTaskListFields.AssignedTo, userId,
- BuildDateRangeOverlapFilter(startDate, dueDate));
- }
- else
- {
- // it is a new task, we don't have to check te task ID
- query.Query = String.Format("<Where><And><Eq><FieldRef Name='{0}' LookupId='TRUE' /><Value Type='Lookup'>{1}</Value></Eq>{2}</And></Where>",
- SampleTaskListFields.AssignedTo, userId,
- BuildDateRangeOverlapFilter(startDate, dueDate));
- }
- bool result = List.GetItems(query).Count > 0;
- return result;
- }
- protected String BuildDateRangeOverlapFilter(DateTime startDate, DateTime endDate)
- {
- StringBuilder sb = new StringBuilder();
- sb.Append(String.Format("<And>{0}{1}</And>",
- BuildSimpleDateFilter(SampleTaskListFields.StartDate, endDate, "Leq"),
- BuildSimpleDateFilter(SampleTaskListFields.DueDate, startDate, "Geq")));
- return sb.ToString();
- }
- protected String BuildSimpleDateFilter(String dateFieldName, DateTime filterDate, String relation)
- {
- String datePattern = "yyyy-MM-ddT00:00:00Z";
- return String.Format("<{0}><FieldRef Name='{1}'/><Value Type='DateTime'>{2}</Value></{0}>", relation, dateFieldName, filterDate.ToString(datePattern));
- }
And that is the outcome of the validation in this case:
Note: Other validation possibilities for Person or Group field type are to check the number of users set (you can allow a single user or multiple ones using the standard tools, but you can not expect exactly three of them), or to check if the specified users are from a specific Active Directory group.
Although the technique illustrated in this post require coding and not so straightforward as specifying a simple formula on the UI, I hope you can use it effectively if the customer requirements demand something more sophisticated.
You can download the sample application from here.