Tuesday, June 12, 2007

Extending the CalendarExtender

The Ajax Control Toolkit comes with an extender that automatically adds a javascript-based calendar popup to any TextBox. While the calendar is certainly nicer than the ASP.NET control that requires a postback for every action, the customization options are severely limited. In particular, the CalendarExtender always allows selection of every single date. For one of our apps we needed to limit the selection to certain days. This is how we did it.

The implementation of the CalendarExtender (mostly) lives in CalendarExtender.cs and the client-side javascript file, CalendarBehavior.js. Nearly everything we need is in the javascript. For our limited scenario, we decided to simply add a property to the control that lets you specify a javascript function to call that returns a bool, indicating whether or not the date is selectable. First, add the new property to the cs file:

[ExtenderControlProperty]
[ClientPropertyName("isDateSelectableFunction")]
public string IsDateSelectableFunction
{
get { return GetPropertyValue("IsDateSelectableFunction", ""); }
set { SetPropertyValue("IsDateSelectableFunction", value); }
}


The ExtenderControlProperty attribute tells the toolkit to magically make your property available on the client side, while the ClientPropertyName attribute tells the toolkit what name to give the client property. GetPropertyValue and SetPropertyValue just move things back and forth from ViewState.

The real work is done in the behavior file. First we have to initialize our new property. This happens right at the top of the file.

this._yearsBody = null;
this._button = null;
//new
this._isDateSelectableFunction = null;


Now we have a private field that will automatically get populated with the value set on the server control. Since javascript doesn't support Properties like C#, we need to add getter and setter methods. Above the definition for "get_animated" add the following

get_isDateSelectableFunction : function() {
return this._isDateSelectableFunction;
},
set_isDateSelectableFunction : function(value) {
this._isDateSelectableFunction = value;
},


Next, we have to use it. There's a lot of javascript to wade through, but the important bits are all in one place. The CalendarExtender has several views, allowing you to select days, zoom out to months, or zoom out to years. Since date selection only happens on the days view, that's the only thing to be modified. The extender builds a single grid to represent the month and then renumbers each cell whenever you switch months. All we're going to do is insert a bit of code when the numbering is happening. This numbering happens in the _performLayout function. Find this function in the js file. There's a big switch statement on this._mode to handle which view is currently active. We're interested in the "days" case.

There are 2 main for loops here. The first (from i = 0, i < 7) renders the headers, while the next one is nested and walks over both weeks and days. Right after

$common.removeCssClasses(dayCell.parentNode,
[ "ajax__calendar_other", "ajax__calendar_active" ]);
Sys.UI.DomElement.addCssClass(dayCell.parentNode,
this._getCssClass(dayCell.date, 'd'));


but before

currentDate = new Date(currentDate.getFullYear(),
currentDate.getMonth(), currentDate.getDate() + 1);


we're going to add our code. At this point, the cell (dayCell) has been populated with the right number and currentDate is set to the date in the cell. All we do is pass this date into our function and take action based on the result. Here's the code:

$common.removeHandlers(dayCell, this._cell$delegates);
if(eval(this.get_isDateSelectableFunction() + "(currentDate);"))
{
$addHandlers(dayCell, this._cell$delegates);
}


What we've done is really simple. First, we unhook the event handlers for the cell. This makes them not clickable (and removes the mouseover highlighting). Next, if the result of our function is true, we add the handlers back. It's important to remove and re-add instead of just doing an "if(!eval) remove" type of thing because the grid is reused for every month, so we must handle both cases every time.

That's all there is to it. An easy additional feature would be to have a separate CSS class for selectable dates. To do that, first add the name of your CSS class to the call to $common.removeCssClasses (e.g [ "ajax__calendar_other", "ajax__calendar_active", "my_new_class ]). Then, put Sys.UI.DomElement.addCssClass(dayCell.parentNode, "my_new_class"); inside the "if(eval)" block.

For completeness you should probably handle disabling the "Today" link at the bottom of the calendar when today isn't a valid date, but given the above information you can handle that the same way.

Here's a snippet of the control used in a page that limits the selectable dates to Mondays only.


<script language="javascript" type="text/javascript">
function isSelectable(x)
{
return x.getDay() == 1; //Mondays
}
</script>

...
<asp:TextBox ID="txtDate" runat="server"></asp:TextBox>

<ajax:CalendarExtender ID="calExtDate" runat="server"
TargetControlID="txtDate" PopupButtonID="imgCal"
IsDateSelectableFunction="isSelectable"></ajax:CalendarExtender>