User Interface - Part 2 - Encapsulating UI Controls
As a continuation to the previous entry on user interface I am now going to cover the topic of encapsulation. This almost goes without saying on any platform, but is worth repeating: whenever using a control that need or may need to be extended, improved or replaced at some point it is a good idea to encapsulate it so any such changes only need to be done in one place.
CalendarExtender -> DatePicker
In my previous blog entry covering my choice of UI technology I mentioned the ASP.NET AJAX Control Toolkit, a set of sample controls for asp.net that showcase some of the new client-side functionality that ships with .net 3.5. Some of these controls are useful right out of the box (download) but in a few cases it still makes sense to encapsulate them. One such example is the calendar extender. The calendar extender adds a calendar dropdown to textboxes, dropdowns or other web form controls through the new concept of extender controls; controls that add client side functionality to server-side controls by adding client side script and css to the page at rendering time. However, the calendar extender does not address keyboard input, validation etc so this is one area where I will add some functionality while encapsulating it. The encapsulation also makes it easy to replace the dropdown part with another calendar dropdown - client based or server based - if needed.

Control Definition (ascx)
To avoid confusion I am calling the encapsulated control DatePicker. The control definition ascx is short and straightforward:

First a textbox is needed for entering and displaying the date:
<asp:TextBox ID="txtDate" runat="server" MaxLength="12" Columns="12" AutoPostBack="false" AutoCompleteType="Disabled" onfocus="this.select();" />
The textbox is immediately followed by an image button displaying a calendar icon, for displaying the calendar extender’s calendar dropdown:
<asp:ImageButton runat="server" ID="btnCalendar" ImageUrl="../images/calendar.png" CausesValidation="false" />
Once the textbox and image button has been defined it is time to add the calendar extender itself, referencing the previous two controls as the target control and popup button.
<act:CalendarExtender ID="calDate" runat="server" TargetControlID="txtDate" Format="d"
PopupButtonID="btnCalendar" FirstDayOfWeek="Monday">
In order to allow for validation of any entered date, and to enforce date fields that are mandatory, throw in a couple of standard asp.net validation controls:
<asp:RequiredFieldValidator ID="rfvDate" ControlToValidate="txtDate" runat="server" ErrorMessage="Please enter a date." Display="None" Enabled="false" /> <asp:CustomValidator runat="server" ID="cvlDate" ErrorMessage="Invalid date format." ClientValidationFunction="ValidateDate" ControlToValidate="txtDate" EnableClientScript="true" Display="None" />
…immediately backed up by the Validation Callout Extender from the Ajax Control Toolkit:
<act:ValidatorCalloutExtender CssClass="vceValidatorCallout" ID="vceDate" runat="server" TargetControlID="cvlDate"> </act:ValidatorCalloutExtender> <act:ValidatorCalloutExtender CssClass="vceValidatorCallout" ID="vceDate2" runat="server" TargetControlID="rfvDate"> </act:ValidatorCalloutExtender>
The validation callout extender is a control that extends the standard asp.net validation controls with a fly-out bubble showing the validation error message in a bubble next to the control where validation failed. This brings the user’s attention directly to the control where validation failed in a much better manner than traditional asp.net inline validation messages or validation summaries.

Server Side Code
The server side code for the control is basically just a few properties for handling enabled/disabled, mandatory/not mandatory, valid date format and for retrieving/setting the value of the control:
using System; using System.Web; using System.Web.UI; using System.Web.UI.HtmlControls; using System.Web.UI.WebControls; using System.Web.UI.WebControls.WebParts; using System.ComponentModel;
namespace HuagatiWebControls
{
public partial class DatePicker : System.Web.UI.UserControl
{
private bool _mandatory = false;
private string _dateFormat = "dd MMM yyyy";
protected void Page_Load(object sender, EventArgs e)
{
//set date format for the calendar extender
calDate.Format = _dateFormat;
//set error message for the format validator
cvlDate.ErrorMessage = "Invalid date, please enter a date in the format "
+ DateTime.Now.ToString(_dateFormat, System.Globalization.CultureInfo.InvariantCulture) + ".";
cvlDate.Attributes.Add("cvl", txtDate.ClientID);
//register client side script ScriptManager.RegisterClientScriptInclude(this, this.GetType(), "DatePickerJS", "scripts/datepicker.js"); }
//presentation date format
[DefaultValue("dd MMM yyyy")]
public string DateFormat
{
get
{
return _dateFormat;
}
set
{
_dateFormat = value;
}
}
//mandatory or optional field?
[DefaultValue(false)]
public bool Mandatory
{
get { return _mandatory; }
set
{
_mandatory = value;
rfvDate.Enabled = _mandatory;
}
}
//validation group that this control belongs to
[DefaultValue(null)]
public string ValidationGroup
{
get
{
return rfvDate.ValidationGroup;
}
set
{
rfvDate.ValidationGroup = value;
cvlDate.ValidationGroup = value;
}
}
//control tab index
[DefaultValue(0)]
public short TabIndex
{
get
{
return txtDate.TabIndex;
}
set
{
txtDate.TabIndex = value;
}
}
//set or get the currently selected date
public DateTime? SelectedDate
{
get
{
DateTime? selectedDate = null;
DateTime dt = DateTime.MinValue;
if (DateTime.TryParseExact(txtDate.Text, _dateFormat,
System.Globalization.CultureInfo.InvariantCulture.DateTimeFormat,
System.Globalization.DateTimeStyles.None, out dt) == true)
{
selectedDate = dt;
}
else if (DateTime.TryParse(txtDate.Text,
System.Globalization.CultureInfo.InvariantCulture.DateTimeFormat,
System.Globalization.DateTimeStyles.None, out dt) == true)
{
selectedDate = dt;
}
return selectedDate;
}
set
{
if (value == null)
{
txtDate.Text = "";
}
else
{
DateTime dt = (DateTime)value;
txtDate.Text = dt.ToString(_dateFormat,
System.Globalization.CultureInfo.InvariantCulture.DateTimeFormat);
}
}
}
//control enabled or disabled?
[DefaultValue(true)]
public bool Enabled
{
get
{
return txtDate.Enabled;
}
set
{
txtDate.Enabled = value;
calDate.Enabled = value;
cvlDate.Enabled = value;
rfvDate.Enabled = value && _mandatory;
}
}
}
}
Keyboard Entry / Client Side Validation
Next up, I want to add some client side code for validating and parsing dates entered directly in the textbox by users so dates can be both validated and formatted properly before a page postback occurs. The client side script for doing so is referenced by the custom validation control cvlDate previously added through the ClientValidationFunction attribute.
The script for doing client side validation looks as follows:
//default date presentation format var _dateFormat = "dd MMM yyyy"; //default date validation formats var _dateValidationPatterns = "dd|dd/MM|dd-MM|dd MMM|dd-MMM|dd MMM yy|dd MMM yyyy|dd-MMM-yyyy|dd/MM/yy|dd/MM/yyyy|ddMMyy|ddMMyyyy";
function ValidateDate(source, clientsideArguments)
{
//get the value to validate
var controlValue = clientsideArguments.Value;
//attempt to parse using presentation format
var newDate = Date.parseLocale(controlValue, _dateFormat);
if (newDate == null)
{
//date could not be parsed using presentation format, attempt parsing using alternative formats
var formats = _dateValidationPatterns.split('|');
var idx = 0;
for (idx in formats)
{
var format = formats[idx].trim();
newDate = Date.parseInvariant(controlValue, format);
if (newDate != null)
{
break;
}
}
�
//if a valid date has not yet been parsed, attempt to parse as a positive offset (+90d, +3m etc)
var unit = null;
var unitCount = null;
if (newDate == null && controlValue.indexOf('+') == 0)
{
unit = controlValue.trim().substring(controlValue.length - 1).toLowerCase();
unitCount = new Number(controlValue.trim().toLowerCase().replace('+', '').replace('d', '').replace('m', ''));
if (unitCount != 'NaN')
{
newDate = new Date();
if (unit.indexOf("m") > -1)
{
newDate.setMonth(newDate.getMonth() + unitCount);
}
else
{
newDate.setDate(newDate.getDate() + unitCount);
}
}
}
//if a valid date has not yet been parsed, attempt to parse as a negative offset (-90d, -3m etc)
if (newDate == null && controlValue.indexOf('-') == 0)
{
unit = controlValue.trim().substring(controlValue.length - 1).toLowerCase();
unitCount = new Number(controlValue.trim().toLowerCase().replace('-', '').replace('d', '').replace('m', ''));
if (unitCount != 'NaN')
{
newDate = new Date();
if (unit.indexOf("m") > -1)
{
newDate.setMonth(newDate.getMonth() - unitCount);
}
else
{
newDate.setDate(newDate.getDate() - unitCount);
}
}
}
}
//check if we got a valid date in the parsing above...
if (newDate != null)
{
//date is valid
$get(source.getAttribute('cvl')).value = newDate.localeFormat(_dateFormat);
clientsideArguments.IsValid = true;
}
else
{
//the date is not valid
clientsideArguments.IsValid = false;
}
}
Also fairly straightforward, it uses the method signature used by the CustomValidator control for client side validation. First it tries to parse the input using the display format, if that fails it attempts to parse it using a pre-defined list of common formats, and thereafter it tries to interpret the input as an offset (e.g. +90d, -90d, +2m etc). This allows for a great deal of flexibility in user input; one can enter only a date, date and month, or date month and year in various formats as illustrated below.

The functions used for parsing and formatting; Date.localeFormat, Date.parseLocale, Date.parseInvariant are all part of the Microsoft Ajax client libraries and include nearly full support for culture specific formatting and parsing by passing translations and other culture specific formatting and parsing information from the .net framework on the server to the client when the scripts are generated, as client side scripts. A handful of cultures are not fully supported but that is mentioned in the community content on msdn, and can be worked around separately as needed.
Configurable Settings / Formatting
In this example I have left some values hard-coded such as display format, parsing list, error messages etc but when implemented these all come from application config settings or user settings and will be set at page_load in all master pages. For server-side properties it is fairly straight-forward to find all instances of the DatePicker control and set the DateFormat, and for the client side it only needs to be emitted as client script once per page load:
public partial class HuagatiResMasterPage : System.Web.UI.MasterPage
{
protected void Page_Load(object sender, EventArgs e)
{
//generate client side script for the config settings that are needed by client script
string dateFormatConfigSettings
= "var _dateFormat = '" + ConfigSettings.DateFormat.Replace("'", "''") + "';\r\n"
+ "var _dateValidationPatterns = '" + ConfigSettings.DateFormatValidation.Replace("'", "''") + "';\r\n";
ScriptManager.RegisterStartupScript(this, this.GetType(), "DateFormatConfigSettings", dateFormatConfigSettings, true);
}
}
Final Result
The final control is easy to use in a page; the markup for adding a date picker is as simple as:
<dtp:DatePicker ID="dtpDatePicker" runat="server" />
…and any changes or improvements that need to be made to date fields system wide can now be done in one single place.
A sample of this control in action is available at http://blog.huagati.com/samples/datepickersample.aspx