In my previous post Custom Validation Step 2: Business Object Validaiton, I showed how the custom validation design that I’m working with could be applied to a Person entity (business object). The key piece of the puzzle is that my Person class contains a ValidationErrors member which is just a generic list of ValidationError objects. This handy list is populated with all of the validation errors for my entity whenever I call the Validate() method. Now it’s time for the payoff. In this post I’ll walk through a basic implementation of the custom validation design on an ASP.Net page. Next week I’ll show a more complex implementation that uses self registering ErrorLabel and ValidationSummary controls.
The PersonForm.aspx Page
We’re going to create a PersonForm.aspx page that will be used to create or edit a person entity. This is a simple data input form with labels and input controls. It looks like this.
The PageErrorList Class
We’re going to create a new member on our page called PageErrors that will contain a list of ValidationError objects, just like the ValidationErrors list on our Person class. This allows us to implement a design where the page’s PageErrors list can contain the ValidationErrors list from the Person object. This is really the key to making the whole design work. We use the exact same mechanism to store page level errors as we use to store validation errors in our business objects. That makes it possible for us to run Validate() on a business object, then pass the resulting ValidationErrors() list to our page, the page can then just append those errors to it’s own PageErrors list.
You may have noticed that I said PageErrors contains a List<ValidationError> just now. That’s because we need PageErrors to do some additional things that a plain generic list isn’t going to handle for us, like appending lists of ValidationError objects, and setting UIFieldNames when needed. So, we’re going to create a wrapper class called PageErrorList.
There are 3 main parts to PageErrorList. First it contains our generic list of ValidationErrors. Second, it contains a FieldMappings dictionary that can be used to map entity FieldNames to the UIFieldNames used by our page (we want error messages to use “Mobile Phone” not “PhoneMobile”). Third, it contains a collection of helper methods that do things like adding a single ValidationError to the Errors list, adding lists of ValidationErrors, and generating a summary of error messages. Here’s the code:
public class PageErrorList
{
// Errors
private List<ValidationError> _errors;
public List<ValidationError> Errors
{
get
{
if (_errors == null) { _errors = new List<ValidationError>(); }
return _errors;
}
set { _errors = value; }
}
// FieldMappings
// Dictionary that contains mappings between the FieldNames and UIFieldNames.
// FieldName is the key, UIFieldName is the value.
private Dictionary<string, string> _fieldMappings;
public Dictionary<string, string> FieldMappings
{
get
{
if (_fieldMappings == null)
{
_fieldMappings = new Dictionary<string, string>();
}
return _fieldMappings;
}
set { _fieldMappings = value; }
}
// MapField
// Helper method to create items in FieldMappings. Main purpose is
// to make clear which item is FieldName and which is UIFieldName.
public void MapField(string FieldName, string UIFieldName)
{
FieldMappings.Add(FieldName, UIFieldName);
}
// Add
// Add a single ValidationError to Errors, and as we add it
// check the FieldMappings to see if we have a UIFieldName
// for this FieldName and set it if we do.
public void Add(ValidationError e)
{
string uiFieldName;
if (FieldMappings.TryGetValue(e.FieldName, out uiFieldName))
{
e.UIFieldName = uiFieldName;
}
Errors.Add(e);
}
// AddList
// Same as Add() but this method takes a list of ValidationErrors.
public void AddList(List<ValidationError> list)
{
foreach (ValidationError e in list) { Add(e); }
}
// GetErrorMessages
// Returns a string that lists all errors and is meant
// to be used for the error summary.
public string GetErrorMessages(string ErrorBullet)
{
System.Text.StringBuilder messages = new System.Text.StringBuilder(512);
foreach (ValidationError e in Errors)
{
messages.Append(ErrorBullet + e.ErrorMessage + "<br />");
}
return messages.ToString();
}
}
Now that we have our new PageErrorList class, we just add a PageErrors property to our PersonForm page. This property contains an object of type PageErrorList as shown below.
// PageErrors
private PageErrorList _pageErrors;
public PageErrorList PageErrors
{
get
{
if (_pageErrors == null)
{
_pageErrors = new PageErrorList();
}
return _pageErrors;
}
set { _pageErrors = value; }
}
Bringing It All Together
Now we have a PageErrors member that’s ready to be our container for all errors related to the page. But how do we use it? There are two main sources of errors on a page like this; entity validation errors, and page level errors. Entity validation errors are exactly what you would think, the errors that we get back when validating an entity. Page level errors are errors that result from UI requirements. What does that mean? Scroll up a bit and look at our UI page. See the Password and Confirm Password fields? Password is a member of our Person entity, so making sure we have a valid password is an example of entity level validation. However, the entity doesn’t have anything to do with Confirm Password. Making sure that the Confirm Password value matches the Password value is purely a requirement of the UI. That’s an example of page level validation.
So, our workflow on this page is simple. A user clicks the save button, we need to run both page level validation and entity validation, if the page passes all validation we save the person, if not we give an error summary. We need to make a ValidatePage() method to handle page level validation. The method just runs the page level validation rules and adds an errors to our PageErrors member. Remember that this method contains only validation rules not covered by the entity(s).
// ValidatePage
protected void ValidatePage()
{
// Password Confirm Password must match - *** Page Validation ***
if (FormHelper.ParseString(txtPassword.Text) != FormHelper.ParseString(txtPasswordConfirm.Text))
{
this.PageErrors.Add(new ValidationError("Page.ConfirmPassword", "Confirm Password and Password don't match"));
}
}
You may have noticed the FormHelper class used in the code above. Whenever I’m getting values from a form I always have repetitive code that does things like null check on a DropDownList.SelectedValue or trim TextBox.Text, so I stick that code in static methods contained in a static FormHelper class. This approach also has the benefit of codifying best practices for getting data from a form. If everyone is using the FormHelper, you don’t have to worry about a junior developer (or me) creating code that will blow up because he isn’t doing something like a null check on ddl.SelectedValue.
Next is the GetPesonFromForm() method. Before we can validate or save a Person object we need to create it from our submitted form data. GetPersonFromForm() does that for us. It also demonstrates how easy this type of thing can be when you use a FormHelper class. When you look at the code below, keep in mind our business object architecture. We use PersonDTO objects to move data between layers of our app. The DTO (Data Transfer Object) is just data container, it’s contains properties for all of the person data fields. The Peson class that we’re creating below is a full fledged business object. It contains both data and behavior. It’s data is stored in a very simple way, it just has a single Data property of type PersonDTO. For more detail take a look at Custom Validation Step 2: Business Object Validation.
// GetPersonFromForm
private BAL.Person GetPersonFromForm()
{
BAL.Person person = PersonRepository.GetNewPerson();
if (this.PersonGuid != CommonBase.Guid_NullValue)
{
person.Data.PersonGuid = this.PersonGuid;
}
person.Data.Name = FormHelper.ParseString(txtName.Text);
person.Data.Email = FormHelper.ParseString(txtEmail.Text);
person.Data.Password = FormHelper.ParseString(txtPassword.Text);
person.Data.TimeZoneId = FormHelper.ParseInt(ddlTimeZone.SelectedValue);
person.Data.City = FormHelper.ParseString(txtCity.Text);
person.Data.State = FormHelper.ParseString(ddlState.SelectedValue);
person.Data.ZipCode = FormHelper.ParseInt(txtZipCode.Text);
person.Data.PhoneHome = FormHelper.ParseString(txtPhoneHome.Text);
person.Data.PhoneMobile = FormHelper.ParseString(txtPhoneMobile.Text);
person.Data.ImAddress = FormHelper.ParseString(txtImAddress.Text);
person.Data.ImType = FormHelper.ParseInt(ddlImType.SelectedValue);
return person;
}
All right. Now we’re ready to create our button click event. After all of the plumbing we’ve created, our event code is very simple. To recap, we just create a person object from our form data, run both page level and entity level validation, then check to see if we have errors, if so show the error summary, if not save the person. Here it is:
// btnSave_Click
protected void btnSave_Click(object sender, EventArgs e)
{
BAL.Person person = GetPersonFromForm();
// Run page and entity level validation
ValidatePage();
this.PageErrors.AddList(person.Validate());
// If any errors were found during page validation or the domain
// object validation then show an error summary.
if (this.PageErrors.Errors.Count != 0)
{
lblErrorSummary.Text = this.PageErrors.GetErrorMessages(“* ”);
}
else
{
PersonRepository.SavePerson(ref person, true);
}
}
So that’s the meat of it but there is one last thing I should cover. Remember when we created our PageErrorList class and we included a dictionary that could be used to store the mappings between entity FieldNames (like PhoneHome) and UIFieldNames used by the page (like Phone Number). If we want to get error messages that match the names we use in our UI page, then we need to set up those mappings. Fortunately, thanks to the plumbing we’ve created, it’s pretty easy. The method below takes a reference to the PageErrorList object as a parameter and then creates the mappings using our MapField() helper method. Note the careful “fully qualified” naming convention used for the first parameter, FieldName. Data members that belong to the Person object are named “Person.FieldName”. Data members that don’t belong to an entity but do exist on the page (like ConfirmPassword) are named “Page.FieldName”. The main thing to remember is that the FieldName we use here in the mappings must match the FieldName we used when we created the ValidationError object back in the entity level Validate() method or in the page level ValidatePage() method. With these mappings in place, our ValidationError objects will know to replace the <FieldName> pseudo tags in our error messages with UI specific names that will work with our Asp.Net page and will make sense to our users. One more thing, for MapFieldNames() to work it needs to be called from some place in the page event lifecycle. I put my call to MapFieldNames in the Page_PreInit() in my FormPageBase class (the base class that I use for all of my data entry style pages).
// MapFieldNames
protected void MapFieldNames(PageErrorList ErrorList)
{
// Password
ErrorList.MapField("Person.Password", "Password");
// ConfirmPassword
ErrorList.MapField("Page.ConfirmPassword", "Confirm Password");
// Name
ErrorList.MapField("Person.Name", "Name");
// Nickname
ErrorList.MapField("Person.Nickname", "Nickname");
// PhoneMobile
ErrorList.MapField("Person.PhoneMobile", "Mobile Phone");
// PhoneHome
ErrorList.MapField("Person.PhoneHome", "Home Phone");
ErrorList.MapField("Person.Email", "Email");
// City
ErrorList.MapField("Person.City", "City");
// State
ErrorList.MapField("Person.State", "State");
// ZipCode
ErrorList.MapField("Person.ZipCode", "Zip Code");
// ImAddress
ErrorList.MapField("Person.ImAddress", "IM Address");
// ImType
ErrorList.MapField("Person.ImType", "IM Type");
// TimeZoneId
ErrorList.MapField("Person.TimeZoneId", "Time Zone");
// LanguageId
ErrorList.MapField("Person.LanguageId", "Language");
}
So that’s our implementation of custom validation on an Asp.Net page, the simple version. Looking back it seems like a lot until you realize that most of what we’ve done is just plumbing that can be written once and then used across every page in your application. The only validation code that you need to create for each individual page is the ValidatePage() method and the MapFieldNames() method. Stuff like GetPersonFromForm() would have had to be written whether you use this model or not. So once initial development is done, your implementation for additional pages is almost trivial, and the benefit of consolidating your business logic inside your business objects is definitely worth it. Remember that we’ve been focusing on validation like is this a valid email, but your validation should also implement rules like is this person allowed to add this feature to their service plan if they don’t have an email saved in the system and no other person in their customer group has an email saved to the system. The latter is the type of validation rule that very clearly does not belong in your UI. Consolidating your validation in one place, using a mechanism that can be understood and used by your UI can really pay big dividends when it comes to extending and maintaining your code.
Next time I’m going to extend this model even further with a new FormBasePage that is an IValidationContainer, and self registering ValidationLabel, ErrorSummary, and MessageSummary controls. These additions will automate a lot of the things that I want to happen in my UI forms like creating a summary whenever there are page errors and giving a visual indicator next to lines where there was an error.
For people who want to see the full PersonForm page class, here is a more or less complete implementation of the things I covered in this post:
public partial class PersonForm : FormPageBase
{
#region "PROPERTIES"
// PageMode
protected enum enumPageMode
{
NULL = 0,
NEW= 1,
EDIT = 3
}
private enumPageMode _pageMode=enumPageMode.NULL;
protected enumPageMode PageMode
{
get
{
// If _pageMode hasn't been set yet then we need to
// pull it from the querystring(). If a known page
// mode isn't found, throw an error.
if (_pageMode == enumPageMode.NULL)
{
string pageMode = Request.QueryString["PAGEMODE"];
if (String.IsNullOrEmpty(pageMode))
{
throw new Exception("Unhandled PageMode");
}
switch (pageMode)
{
case "NEW":
_pageMode = enumPageMode.NEW;
break;
case "EDIT":
_pageMode = enumPageMode.EDIT;
break;
}
}
return _pageMode;
}
}
// PersonGuid
private Guid _personGuid = CommonBase.Guid_NullValue;
protected Guid PersonGuid
{
get
{
if (_personGuid == CommonBase.Guid_NullValue)
{
if (this.ViewState["__PersonGuid"] != null)
{
_personGuid=(Guid)ViewState["__PersonGuid"];
}
}
return _personGuid;
}
set
{
_personGuid = value;
this.ViewState["__PersonGuid"] = value;
}
}
#endregion
#region "PAGE EVENTS"
//--------------------------------------------------------
// Page_Load
//--------------------------------------------------------
protected void Page_Load(object sender, EventArgs e)
{
if (!this.IsInitialized) { InitializePage(); }
}
#endregion
#region "OTHER EVENTS"
//--------------------------------------------------------
// btnSave_Click
//--------------------------------------------------------
protected void btnSave_Click(object sender, EventArgs e)
{
BAL.Person person = GetPersonFromForm();
// Run page and entity level validation
ValidatePage();
this.PageErrors.AddList(person.Validate());
// If any errors were found during page validation or the domain
// object validation then show an error summary.
if (this.PageErrors.Errors.Count != 0)
{
lblErrorSummary.Text = this.PageErrors.GetErrorMessages(“* ”);
}
else
{
PersonRepository.SavePerson(ref person, true);
}
}
#endregion
#region "CLASS METHODS"
//--------------------------------------------------------
// GetPersonFromForm
//--------------------------------------------------------
private BAL.Person GetPersonFromForm()
{
BAL.Person person = PersonRepository.GetNewPerson();
if (this.PersonGuid != CommonBase.Guid_NullValue)
{
person.Data.PersonGuid = this.PersonGuid;
}
person.Data.Name = FormHelper.ParseString(txtName.Text);
person.Data.Email = FormHelper.ParseString(txtEmail.Text);
person.Data.Password = FormHelper.ParseString(txtPassword.Text);
person.Data.TimeZoneId = FormHelper.ParseInt(ddlTimeZone.SelectedValue);
person.Data.City = FormHelper.ParseString(txtCity.Text);
person.Data.State = FormHelper.ParseString(ddlState.SelectedValue);
person.Data.ZipCode = FormHelper.ParseInt(txtZipCode.Text);
person.Data.PhoneHome = FormHelper.ParseString(txtPhoneHome.Text);
person.Data.PhoneMobile = FormHelper.ParseString(txtPhoneMobile.Text);
person.Data.ImAddress = FormHelper.ParseString(txtImAddress.Text);
person.Data.ImType = FormHelper.ParseInt(ddlImType.SelectedValue);
return person;
}
//--------------------------------------------------------
// InitializePage
// Sets the initial state of the page.
//--------------------------------------------------------
override protected void InitializePage()
{
// Bind all ddl items.
BindDdlTimeZone();
BindDdlState();
BindDdlImType();
// Populate form values for the state we're in
switch (PageMode)
{
case enumPageMode.EDIT:
{
// Existing person.
BAL.Person thisPerson = PersonRepository.GetPersonByPersonGuid(this.PersonGuid);
PopulatePage(ref thisPerson);
break;
}
case enumPageMode.NEW:
{
// New person.
SetPageDefaults();
break;
}
}
// Set the initialized flag
this.IsInitialized = true;
}
//--------------------------------------------------------
// SetPageDefaults
//--------------------------------------------------------
protected void SetPageDefaults()
{
// do nothing for now.
}
//--------------------------------------------------------
// PopulatePage
//--------------------------------------------------------
protected void PopulatePage(ref BAL.Person thisPerson)
{
// Set all data values. Be sure to check for null values against
// the null defaults defined in CommonBase.
txtName.Text = thisPerson.Data.Name == CommonBase.String_NullValue ? String.Empty : thisPerson.Data.Name;
txtEmail.Text = thisPerson.Data.Email == CommonBase.String_NullValue ? String.Empty : thisPerson.Data.Email;
txtPassword.Text = thisPerson.Data.Password == CommonBase.String_NullValue ? String.Empty : thisPerson.Data.Password;
txtPasswordConfirm.Text = txtPasswordConfirm.Text;
}
//--------------------------------------------------------
// BindDdlTimeZone
//--------------------------------------------------------
protected void BindDdlTimeZone()
{
ddlTimeZone.DataSource = TimeZoneRepository.GetAllUS();
ddlTimeZone.DataTextField = "MicrosoftId";
ddlTimeZone.DataValueField = "TimeZoneId";
ddlTimeZone.DataBind();
ListItem selectOne = new ListItem("Select One", "");
ddlTimeZone.Items.Insert(0, selectOne);
}
//--------------------------------------------------------
// BindDdlState
//--------------------------------------------------------
protected void BindDdlState()
{
ddlState.DataSource = StateRepository.GetAll();
ddlState.DataTextField = "StateName";
ddlState.DataValueField = "StateCode";
ddlState.DataBind();
ListItem selectOne = new ListItem("Select One", "");
ddlState.Items.Insert(0, selectOne);
}
//--------------------------------------------------------
// BindDdlImType
//--------------------------------------------------------
protected void BindDdlImType()
{
ddlImType.DataSource = ImTypeRepository.GetAll();
ddlImType.DataTextField = "ImName";
ddlImType.DataValueField = "ImId";
ddlImType.DataBind();
ListItem selectOne = new ListItem("Select One", "");
ddlImType.Items.Insert(0, selectOne);
}
//--------------------------------------------------------
// MapFieldNames
// Required for PageBase implementation. Method maps full
// Entity field names to the UI Names that need to be
// used in error messages. Once fields are mapped, the
// PageErrors object can automatically generate usable
// error messages for validation errors. If no fields
// need to be mapped then just create an empty method.
//--------------------------------------------------------
override protected void MapFieldNames(PageErrorList ErrorList)
{
// Password
ErrorList.MapField("Person.Password", "Password");
// ConfirmPassword
ErrorList.MapField("Page.ConfirmPassword", "Confirm Password");
// Name
ErrorList.MapField("Person.Name", "Name");
// Nickname
ErrorList.MapField("Person.Nickname", "Nickname");
// PhoneMobile
ErrorList.MapField("Person.PhoneMobile", "Mobile Phone");
// PhoneHome
ErrorList.MapField("Person.PhoneHome", "Home Phone");
ErrorList.MapField("Person.Email", "Email");
// City
ErrorList.MapField("Person.City", "City");
// State
ErrorList.MapField("Person.State", "State");
// ZipCode
ErrorList.MapField("Person.ZipCode", "Zip Code");
// ImAddress
ErrorList.MapField("Person.ImAddress", "IM Address");
// ImType
ErrorList.MapField("Person.ImType", "IM Type");
// TimeZoneId
ErrorList.MapField("Person.TimeZoneId", "Time Zone");
// LanguageId
ErrorList.MapField("Person.LanguageId", "Language");
}
//--------------------------------------------------------
// ValidatePage
//--------------------------------------------------------
protected void ValidatePage()
{
// Password Confirm Password must match - *** Page Validation ***
if (FormHelper.ParseString(txtPassword.Text) != FormHelper.ParseString(txtPasswordConfirm.Text))
{
this.ValidationBox.PageErrors.Add(new ValidationError("Page.ConfirmPassword", "Confirm Password and Password don't match"));
}
}
#endregion
}
I don't suppose you have an example of your FormHelper class laying around somewhere? =)
ReplyDelete