Introduction
While building a web-based, enterprise wide system for a company spanning multiple continents, we frequently ran into localization-type issues that spanned the English language, let alone non-English languages. The most significant data problem we encountered within English-speaking countries was the entry of dates. The application initially accepted dates in textboxes, causing much confusion when the date was saved in the database. In the United States (US), the date 4/5/2003 is April 5th, 2003. In the United Kingdom (UK), this date is May 4th, 2003. To make matters worse, some UK users had US localization set on their computers, and some had UK localization set on their computers.
There was no reliable method for determining the format of date the user really was using (mm/dd/yyy or dd/mm/yyyy) for data entry. The application displays dates in the format dd-Mon-yyyy which is unambiguous to the users. The users found typing that format clunky, and labels near the fields showing the proper format and addtional training of the users was of little or no help to the problem. Thus was born an idea for a date control based solely on list boxes. I chose to create a Composite Control over a User Control so I could deploy the control in more than one application simultaneously.
Background
Composite controls must implement the INamingContainer
interface, so that multiple instances of the control on the page have unique names. Also, a composite control must override the CreateChildControls
method to instantiate the child controls. I chose to implement IPostBackDataHandler
to manage the state of the control over postbacks instead of hooking to the built-in events of the child controls. This was mainly due to the fact that my control produced one value, yet used three controls. If the user changed all three, each control raised a change event and caused needless execution of the code for maintaining the single value of the control. The IPostBackDataHandler
interface allows the control to check the state of all three and then raise a single change event.
The DateControl Class
Public Properties
SelectCurrentDate |
A boolean that causes the control's value to be initially set to the current date. |
Value |
A string representing the value of the three controls in the format "mm/dd/yyyy". A blank value is the empty string. |
YearsBack |
An integer for setting the number of years into the past that the control renders in the year listbox. |
Public Events
Change |
Occurs when any of the listboxes change. |
To instantiate the control on a page, first you need to register it on the page:
<%@ Register TagPrefix="custom" namespace="CustomControls"
assembly="YourDLLName"%>
Then in the HTML:
<custom:datecontrol id="TheDate" runat="server" selectcurrentdate="true">
</custom:datecontrol>
How the Date Control Works
The control consisits of three child controls but exposes only one value which is the combination of all three children. The control is pretty straightforward in the CreateChildControls
method that is overridden. Each listbox takes the appropriate array and binds it. The days and months are static, the years array is calculated. When the control's Value
property is set, the SetSelected
method splits the value and stores the value of each control separately for handling the listboxes on postback, and the single Value
property is saved in ViewState. The control is not concerned with the validity of the data, only the format.
public string Value
{
get
{
string s = (string)ViewState["Value"];
if(s == null)
return String.Empty;
else
return s;
}
set
{
this.SetSelected(value);
ViewState["Value"] = value;
}
}
private void SetSelected(string When)
{
string[] ResultList;
Regex DateSplitter = new Regex("^\\d{1,2}\\/\\d{1,2}\\/\\d{4}$");
if(DateSplitter.IsMatch(When))
{
char divider = '/';
ResultList = When.Split(divider);
SelectedMonth = Int32.Parse(ResultList[0]);
SelectedDay = Int32.Parse(ResultList[1]);
SelectedYear = Int32.Parse(ResultList[2]);
}
else
{
SelectedDay = -1;
SelectedMonth = -1;
SelectedYear = -1;
}
}
The control implements IPostBackDataHandler
to manage resetting the value of the control when it changes on PostBack, and also to reset the value if changes occurred. For each control it checks the posted selectedIndex/value and compares it to the selectedIndex/value restored from ViewState. If a change occurred then Value
of the control gets updated. Any listbox not explicitly set gets a value of -1, and any listbox whose value was removed (the first blank item in the listbox was selected) is also set to -1. This may seem a little odd, but it will help tremendously for validation of the control. When LoadPostData
returns true, RaisePostDataChangedEvent
is called by ASP.Net so the control's Change
event fires.
bool IPostBackDataHandler.LoadPostData(string postDataKey,
NameValueCollection postCollection)
{
int Day = 0;
int Month = 0;
int Year = 0;
bool Changed = false;
string MonthName;
if(postCollection[this.UniqueID.ToString() + ":Day"] != "")
{
Day = Int32.Parse(postCollection[this.UniqueID.ToString() +
":Day"]);
if (Day != this.SelectedDay)
{
this.SelectedDay = Day;
Changed = true;
}
}
else
{
if(this.SelectedDay != -1)
{
this.SelectedDay = -1;
Changed = true;
}
}
MonthName = postCollection[this.UniqueID.ToString() + ":Month"];
if (MonthName != "")
{
Month = Array.IndexOf(this.MonthArrayLong, MonthName);
if(Month != this.SelectedMonth)
{
this.SelectedMonth = Month;
Changed = true;
}
}
else
{
if(this.SelectedMonth != -1)
{
this.SelectedMonth = -1;
Changed = true;
}
}
if(postCollection[this.UniqueID.ToString() + ":Year"] != "")
{
Year = Int32.Parse(postCollection[this.UniqueID.ToString()
+ ":Year"]);
if (Year != this.SelectedYear)
{
this.SelectedYear = Year;
Changed = true;
}
}
else
{
if(this.SelectedYear != -1)
{
this.SelectedYear = -1;
Changed = true;
}
}
if(this.SelectedDay == -1 && this.SelectedMonth == -1 &&
this.SelectedYear == -1)
this.Value = "";
else
{
string NewDate = this.SelectedDay.ToString() + "/";
NewDate += this.SelectedMonth.ToString() + "/";
NewDate += this.SelectedYear.ToString();
this.Value = NewDate;
}
return Changed;
}
Next Step: Validation
As you may have noticed by now, the control will return values like "4/-1/1975" or "-1/-1/2003". It will never return -1/-1/-1, as that is recognized and being empty and returns an empty string instead. Database date fields are notoriously picky, and will not accept a partial date like 4/2003 even though we use them in common speech and writing. The individual values set at -1 allow for effective validation of the control and contain values unacceptable to a database date field so that bad data cannot inadvertently enter the database. Instead of rolling the validation into the control, I created a validator to go along with the date control. Validation could occur via a custom validator, but I liked my own better for deploying the client script. The DateControlValidator
inherits from BaseValidator
and only implements one custom property.
DateControlValidator
Public Properties
Required |
A boolean that causes the validator to check that all three fields are not blank when true. When false the validator will allow all three fields to be blank simultaneously. |
The custom-written validator was much more of a challenge than the control. It works seamlessly with the Microsoft validators and behaves as they do with regard to UpLevel and DownLevel browsers and the EnableClientScript
property. The JavaScript contains one main function, DateControlIsValid
, which checks the value of Required
and acts accordingly. The validator emits it's own error messages (both client and server), it ignores what the user inputs into the ErrorMessage
property of the validator. This is due to the multiple distinct error conditions that may occur:
- An incomplete state, such as 4/-1/2003 where one or more of the three child controls has not been selected.
- An invalid date, such as September 31, 2003 (September has only 30 days).
- The DateControl is required and no child controls were selected.
One error message cannot provide enough information for the user to differentiate between these problems and resolve the error condition easily.
A custom-written validator must override EvaluateIsValid
. Additionally, to support the use of client script the validator must override AddAttributesToRender
and OnPreRender
. A validator is rendered as a <div>
on the HTML that is emitted to the client. The AddAttributesToRender
places the necessary attributes into the <div>
so that the Microsoft-written client-side validation routines find the required data and can manipulate the <div>
properly when client validation occurs. The Micrsoft-written Javascript can be found in WebUIValidation.js in the aspnet_client folder heirarchy.
protected override void AddAttributesToRender(HtmlTextWriter writer)
{
if(this.DetermineRenderUplevel() && this.EnableClientScript)
{
base.AddAttributesToRender(writer);
writer.AddAttribute("controltovalidate", this.ControlToValidate);
writer.AddAttribute("evaluationfunction", "DateControlIsValid");
writer.AddAttribute("display", this.Display.ToString());
writer.AddAttribute("style", "display:none");
writer.AddAttribute("errormessage", this.ErrorMessage);
if(ControlRequired)
writer.AddAttribute("required", "true");
else
writer.AddAttribute("required", "false");
}
}
The OnPreRender
routine hooks the JavaScript file for the custom-written validator into the HTML and into the Microsoft client-side validation routines. For UpLevel browsers, the Microsoft client validation functions are hooked to the HTML control's client events dynamically when the page loads. Since the DateControl
is really three separate HTML controls, the Microsoft routines cannot identify a single HTML control to bind to the validator's client validation function, so they disable the validator. In fact, if the validator was bound to only one of the three controls, the DateControl would not validate properly. The OnPreRender
routine emits a custom start-up script that loops through the DateValidator controls (<div>
) on the HTML page and enables each of them to counteract the behavior of the Microsoft-written JavaScript. Microsoft does supply a routine that accomplishes the same thing, but it causes the validation to occur with the onpageload
event of the HTML<body>
). Required DateControls that start out blank immediately report an error condition even though the user did nothing.
protected override void OnPreRender(EventArgs e)
{
if(this.DetermineRenderUplevel() && this.EnableClientScript)
{
string EOL = Environment.NewLine;
StringBuilder script = new StringBuilder();
this.RegisterValidatorCommonScript();
script.Append("<script language="'javascript'" " +
"src='../include/date_control.js'></script>" + EOL);
Page.RegisterClientScriptBlock("DateValidate", script.ToString());
string element = "document.all[\"" + ClientID + "\"]";
Page.RegisterArrayDeclaration("Date_Controls", element);
Page.RegisterArrayDeclaration("Page_Validators", element);
script.Remove(0, script.Length - 1);
script.Append("<script language="'javascript'">");
script.Append(EOL);
script.Append("var x;");
script.Append(EOL);
script.Append("for (x = 0; x < Date_Controls.length; x++)");
script.Append(EOL);
script.Append("Date_Controls[x].enabled = true;");
script.Append(EOL);
script.Append("</script>");
script.Append(EOL);
Page.RegisterStartupScript("SetEnabled", script.ToString());
}
}
Future Improvements
The DateControl
certainly has further to go, but these changes were not an immediate need, so they have yet to be implemented:
- Support for Data binding
- Inherit from WebControl
- Designer Support
A VB.Net version of the DateControl
and DateControl
validator classes is included in the .zip files.