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>

20 comments:

Anonymous said...

This is nice, however, does this mean that you have to maintain your own custom version of the ajax toolkit from here on out? How do you roll in future updates from MS or codeplex.com? I like the sort of solutions when using a 3rd party component that you can build on top of from a seperate, external code file.

Code Monkey said...

Unfortunately you do have to maintain your own version after doing this. As it stands now, the calendar js (and most of the other js files) don't have any defined extensibility points, so it's tough to make significant changes without forking.

Gaetano said...

This is nice!!!
But there ae problems with previous, next month, and also with Month name.

Why??

Code Monkey said...

What sort of problems are you having?

Nothing you are changing should have any effect on the day names or the previous/next functionality.

Anonymous said...

Hi,
I was wondering how can you disable all the dates that are past dates? What should the isSelectable(x) function return?

Anonymous said...

I just figure that one out, but I get an error when clicking in the textbox "Handler was not added through the Sys.UI.DomEvent.addHandler method."

Anyone has any thoughts on this?

Anonymous said...

I am getting the same error - "Handler was not added through the Sys.UI.DomEvent.addHandler method."

But this happens, only second time I click on the calendar button. Can anyone help?

Anonymous said...

In regards to "Handler was not added through the Sys.UI.DomEvent.addHandler method.",

all i did was instead of

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


i used this

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

Anonymous said...

Where can I download your code?

Thanks

Code Monkey said...

The only code available is what I've put in the entry. If you're having trouble reproducing what I've done, please let me know.

Thanks.

Anonymous said...

Followed the instructions, made the
changes in order to limit day selection to Mondays and it worked,
though like gaetano said, can't move to previous or next month when clicking on arrows.
Calendar will move the table a bit sideways like adding a new column then it freezes. Any idea how to fix that in the code?

Code Monkey said...

It sounds like you might be unhooking the handlers in the wrong place. You want to only get the cells that contain a number.

I usually use a Firefox plugin called Firebug to debug javascript stuff. You might give that a try.

It's been nearly a year since I did this so it isn't as fresh in my mind as it used to be.

Anonymous said...

hi,

its good,but i hv faced a problem i.e i didnt get .js and cs files....plz send the solution bcz i want to do that in my appication

Anonymous said...

Hi,
Your post is nice & quite helpful too, But when I implemented this for modifying the Calender Extender I/m getting the error,
Which says: Sys.InvalidOperationException: Sys.InvalidOperationException: Handler was not added through the Sys.UI.DomEvent.addHandler method.

I guess when I implement the following line of your post the error comes:
function isSelectable(x)
{ return x.getDay() == 1;
}

if I remove these lines calendar works as normal calender.

Can you please tell me how to solve this problem?

I want to disable all the date which prior to current date.

Anonymous said...

Can you please tell me how to disable dates prior to current date

Ismael said...

We are using the CalendarExtender, but would all would like to allow the user to to enter the date manually using a javascript to format the date as they type in the textbox MM/DD/YYYY format. Can this be donw.

Anonymous said...

Just a quick comment saying that If you want to use:

if (!cell.handlerRemoved)
{
$common.removeHandlers(cell, this._cell$delegates);
cell.handlerRemoved = true;
}

When you add the Handlers to the cell you need to then assign the handlerRemoved = true
$addHandlers(cell, this._cell$delegates);
cell.handlerRemoved = false;

Also adapting this code I got it to accept a min/max date range. Thanks for this Code Monkey

beaugirl said...

Thanks for the solution! I'm using the below code successfully and I've noticed that selecting a date becomes unacceptably slow (waiting for the date to populate the textbox and the calendar to disappear) after changing the date about 3 times. Each selection after the first one (assuming an indecisive user) is progressively slower. Any suggestions?

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

if ((null != _firstValid && currentDate < _firstValid) || (null != _lastValid && currentDate > _lastValid)) {
if (!dayCell.handlerRemoved) {
$common.removeHandlers(dayCell, this._cell$delegates);
dayCell.handlerRemoved = true;
}
$common.removeCssClasses(dayCell.parentNode, ["ajax__calendar_hover"]);

Sys.UI.DomElement.addCssClass(dayCell.parentNode, "invalid-date");
}
else {

$addHandlers(dayCell, this._cell$delegates);
dayCell.handlerRemoved = false;
}

Stephie said...

Could anyone help me with this javascript function:

function isSelectable(x)
{ return x.getDay() == 1;
}

what exactly is the "x".

I create the calendar dynamically in the codebehind

ScriptManager sc = new ScriptManager();
AjaxControlToolkit.CalendarExtender ajaxCal = new AjaxControlToolkit.CalendarExtender(); ajaxCal.IsDateSelectableFunction = "isSelectable"; ajaxCal.Format = "dd.MM.yyyy";
ajaxCal.TargetControlID = txtBox.ID;
ajaxCal.PopupButtonID = imgBtn.ID; sc.Controls.Add(ajaxCal);

and want to put the function also in the codebehind (aspx.cs). Therefore I'm trying to understand what I have to do.

Thanks in advance

@|\/|O|_ said...

That’s fine but where will I get the AJaxControltoolkit source file and calenderBehavior.js