DarioSantarelli.Blog(this);

[ASP.NET MVC 2] Splitting DateTime in drop-down lists and model binding

Posted by dariosantarelli on December 26, 2010

OK this is not the classic DateTime picker bound to a textbox… with a jQuery calendar ;).
If you need a custom datetime editor template that splits the datetime parts in drop-down lists like this…

<%= Html.EditorFor(model => model.BirthDate, “Date”) %>

…or like this…

 

<%= Html.EditorFor(model => model.EventDateTime, “DateTime”) %>

…then this post may help you. As you should know, in ASP.NET MVC 2 the default model binder has some difficulties to combine splitted datetime parts in the View. So, if you need to define a DateTime property in your model and make a custom editor template that splits the DateTime parts in different controls (e.g. TextBox and/or DropDownList), first you should read this smart solution by Scott Hanselman. The idea is to separate the way we render the month field, the day field, the year field etc. from the mechanism that will assemble them back in a DateTime structure for model binding.
Starting from the Global.asax, the first thing to do is to register the Scott’s Custom Model Binder and then specify all the available options (the strings there are the suffixes of the fields in your View that will be holding the Date, the Time, the Day etc.)

ModelBinders.Binders[typeof(DateTime)]  = new DateTimeModelBinder()
{
  Date = "Date", // Date parts are not splitted in the View
                 // (e.g. the whole date is held by a TextBox  with id “xxx_Date”)
  Time = "Time", // Time parts are not  splitted in the View
                 // (e.g. the whole time  is held by a TextBox with id “xxx_Time”)
  Day = "Day",  
  Month = "Month",
  Year = "Year",
  Hour = "Hour",
  Minute = "Minute",
  Second = "Second"
};


Now, let’s have a look to template editors. In Views\Shared\EditorTemplates directory we can put two simple templates: Date.ascx and DateTime.ascx. The former renders only the drop-down lists for the date part of the DateTime structure (Month, Day, Year), while the latter renders the time part too. Here the code for Date.ascx:

<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<System.DateTime>" %>
<%@ Import Namespace="System.Threading" %>

<%= Html.DropDownListFor(dateTime => dateTime.Month, Enumerable.Range(1, 12).Select(i => new SelectListItem                          
{                              
  Value = i.ToString(),                              
  Text = Thread.CurrentThread.CurrentUICulture.DateTimeFormat.GetMonthName(i),                             
  Selected = (i == Model.Month && Model != DateTime.MinValue && Model != DateTime.MaxValue)                         
}),"-- Month --")%>  /

<%= Html.DropDownListFor(dateTime => dateTime.Day, Enumerable.Range(1, 31).Select(i => new SelectListItem                          
{                              
  Value = i.ToString(),                              
  Text = i.ToString(),                             
  Selected = (i == Model.Day && Model != DateTime.MinValue && Model != DateTime.MaxValue)                         
}), "-- Day --")%> /

<%= Html.DropDownListFor(dateTime => dateTime.Year, Enumerable.Range(DateTime.Now.Year-110, 110).Select(i => new SelectListItem                           
{                                                             
  Value = i.ToString(),                               
  Text = i.ToString(),                              
  Selected = (i == Model.Year && Model != DateTime.MinValue && Model != DateTime.MaxValue)                          
}), "-- Year --")%>

<%= Html.HiddenFor(dateTime => dateTime.Hour)%>
<%= Html.HiddenFor(dateTime => dateTime.Minute)%>
<%= Html.HiddenFor(dateTime => dateTime.Second)%>

That’s all!
Note that in the template editor above, the Hour, Minute and Second parts are rendered as HTML hidden fileds, because the Scott’s DateTimeModelBinder configured in the Global.asax expects a value for all the six parts of the splitted DateTime structure. It’s just a clean workaround to make the Scott’s model binder work without any change to the original code. In a real implementation hidden fields should be not required ;).

Now, what about validation? Well, both client-side and server-side validations are quite trivial: the server-side validation can be obtained through a custom ValidationAttribute that checks if the DateTime value is correct (e.g. the value should be not equal to DateTime.MinValue or DateTime.MaxValue).

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class DateRequiredAttribute : ValidationAttribute
{       
   public DateRequiredAttribute() : base() { }

   public override string FormatErrorMessage(string name)
   {
     return string.Format(CultureInfo.CurrentUICulture, ErrorMessageString, name);
   }
  public override bool IsValid(object value)
   {
     DateTime dateTime = (DateTime)value;
     return (dateTime != DateTime.MinValue && dateTime != DateTime.MaxValue);           
   }
}

The corresponding client-side validation adapter can be implemented by deriving the DataAnnotationsModelValidator class. It allows us to specify a remote validation rule from the client. In this scenario, the part of the DateTime structure that could be validated is the Date part.
So, we can create a SplittedDateRequiredValidator in order to check if each drop-down is holding a valid value. To accomplish this requirement, a simple solution is to make the client-side validator aware of the IDs of the <select> elements holding the DateTime’s Month, Day and Year values.

public sealed class SplittedDateRequiredValidator : DataAnnotationsModelValidator<DateRequiredAttribute>
{
   private string _message;
   private string _dayField;
   private string _monthField;
   private string _yearField;

   public SplittedDateRequiredValidator(ModelMetadata metadata, ControllerContext context, DateRequiredAttribute attribute)
                                        : base(metadata, context, attribute)
   {
      _message = attribute.ErrorMessage;
       _dayField = metadata.PropertyName + "_Day";
       _monthField = metadata.PropertyName + "_Month";
       _yearField = metadata.PropertyName + "_Year";           
   }

   public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
   {
      ModelClientValidationRule rule = new ModelClientValidationRule
      {
         ErrorMessage = _message,
         ValidationType = "splittedDateRequiredValidator"               
      };

      rule.ValidationParameters.Add("dayFieldId", _dayField);
      rule.ValidationParameters.Add("monthFieldId", _monthField);
      rule.ValidationParameters.Add("yearFieldId", _yearField);

      return new[] { rule };
   }
}

Before looking at javascript validator code, let’s register the SplittedDateRequiredValidator as the client-side validation adapter for all model properties decorated with the DateRequiredAttribute. To accomplish that, we have to put the following line of code in the Global.asax…

DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(DateRequiredAttribute), typeof(SplittedDateRequiredValidator));

 

Finally, the client-side validator will evaluate the selected index of the drop-down lists in order to ensure that the user has selected a valid date (note that the isValidDate function simply checks if the users has specified an existing date).

Sys.Mvc.ValidatorRegistry.validators.splittedDateRequiredValidator = function (rule) {        
  var dayFieldId = rule.ValidationParameters.dayFieldId;    
  var monthFieldId = rule.ValidationParameters.monthFieldId;    
  var yearFieldId = rule.ValidationParameters.yearFieldId;    
  return function (value, context) {                
    var dayIdx = $get(dayFieldId).selectedIndex;        
    var monthIdx = $get(monthFieldId).selectedIndex;        
    var yearIdx = $get(yearFieldId).selectedIndex;        
    if (dayIdx === 0 || monthIdx === 0 || yearIdx === 0) return false;        
    else return isValidDate(parseInt($get(yearFieldId).value), monthIdx, dayIdx);     
  };
};


function
isValidDate(y, m, d) {
var date = new
Date(y, m – 1, d);
var convertedDate = “”
+ date.getFullYear() + (date.getMonth() + 1) + date.getDate();
var givenDate = “”
+ y + m + d;
return (givenDate == convertedDate);
}

Ok let’s put everything together!
Assuming that our model defines a property ”BirthDate” like this…

[DateRequired(ErrorMessage = “Invalid date. Please specify valid values!”)]
[DataType(DataType.Date)]
[
DisplayName(“Birthdate”
)]
public DateTime BirthDate { get; set; }

… if we put the following code in our View…

<% Html.EnableClientValidation(); %>


<%= Html.EditorFor(m => m.BirthDate, “Date”) %><br />
<%= Html.ValidationMessageFor(m => m.BirthDate)
%>

…the output would be, for example, the following…

HTH

11 Responses to “[ASP.NET MVC 2] Splitting DateTime in drop-down lists and model binding”

  1. Hi,

    I’m trying to utilize this is very useful control in ASP.net MVC using razor. In the JavaScript code you reference metadata.PropertyName + “_Day”, but nowhere in the aspx is an ID set on the control. How’s the ID supposed to get set?

    …Stefan

  2. Hi Stefan,
    Well, the expression metadata.PropertyName + "_Day" represents the id of the client-side HTML drop down that holds the day part of the DateTime (in my example, the output HTML is ). The client-side model validator uses this id (passed as validation parameter) to find the right drop down in the DOM.
    No id must be set explicitly in the aspx view, because it will be auto-generated at rendering time, according to the model binder specifications.

    Best Regards

  3. Ryan said

    Awesome!

    I’m using mvc 3 and razor and was just wondering where do I put the javascript?

  4. Johnston Mwakazi said

    As a beginner, it is not clear where to put the different snippets of code. Would you post the code somewhere, please

  5. anoop said

    Hi,

    Thanks for the post, i am trying to resolve an issue with the validation error.

    When the validation is fired, it is not adding the default “input-validation-error” css-class, to highlight the error.
    Can you please suggest what should be done get this working on both client & server validation?

    Thanks,
    Anoop

    • Brian said

      I’m running into a similar issue. The entire control works, I have it set up for MVC 3 non-razor but I can not get the validation message to show for the life of me 😦

  6. press release submission…

    [ASP.NET MVC 2] Splitting DateTime in drop-down lists and model binding « DarioSantarelli.Blog(this);…

  7. Andrew said

    I’m finding that GetClientValidationRules is only called if I use TextBoxFor(datetime => datetime, … ie. adding a suffix on the end means that the validation attributes are not rendered. This is with MVC4 using IClientValidatable interface on the DateRequiredAttribute instead of creating and registering the separate validator object.

  8. Andrew said

    Ahh, I think that’ll be because I’m using jquery validate and unobtrusive validate – they must handle this differently than Microsoft’s old implementation of validation scripts.

  9. Faisal said

    I am using a Model class in which one of the fields is of DateTime type.
    How do you enforce this field to use Hanselman’s custom DateAndTimeModelBinder
    The attribute to decorate a model to use a certain modelbinder is [ModelBinder(typeof())] and this is at class declaration level.

  10. Faisal said

    Hi

    I realise that this article was written for MVC2 but it pretty much zeroes in on a requirement that I have. I have been trying to re-write this for an MVC5 project with less than full success in terms of functionality and best practice.
    I had trouble getting the “data-” attributes to render on the select tags. So I had to resort to brute-forcing them into the template

    @Html.DropDownListFor(m => m.Month, Enumerable.Range(1, 12).Select(i => new SelectListItem
    {
    Value = i.ToString(CultureInfo.InvariantCulture),
    Text = Thread.CurrentThread.CurrentUICulture.DateTimeFormat.GetMonthName(i),
    Selected = (i == Model.Month && Model != DateTime.MinValue && Model != DateTime.MaxValue)
    }), “– Month –“,
    new Dictionary() { { “data-val-splitdate”, “Invalid date value” } }
    )

    the resulting attribute is data-val-splitdate=”Invalid date value” , which hooks the markup to the jquery unobtrusive callback.

    I have written the script which only partially works, I can’t get options.message to display when the date is in invalid state.
    Would really appreciate a review of this from anyone who has developed this solution for an MVC4/5 solution.

    This is the code I’ve written for GetClientValidationRules:

    public IEnumerable GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
    _message = FormatErrorMessage(base.ErrorMessage);
    _dayField = metadata.PropertyName + “_Day”;
    _monthField = metadata.PropertyName + “_Month”;
    _yearField = metadata.PropertyName + “_Year”;

    var rule = new ModelClientValidationRule
    {
    ErrorMessage = _message,
    ValidationType = “splitdate”
    };
    rule.ValidationParameters.Add(“dayFieldId”, _dayField);
    rule.ValidationParameters.Add(“monthFieldId”, _monthField);
    rule.ValidationParameters.Add(“yearFieldId”, _yearField);
    yield return rule;
    }

    This is the client-side script:

    $.validator.unobtrusive.adapters.add(‘splitdate’, function (options) {
    //alert(‘in adapters.add’);
    var params = {

    dayFieldId: ‘_Day’,
    monthFieldId: ‘_Month’,
    yearFieldId: ‘_Year’
    };
    options.rules[‘splitdate’] = params;

    //alert(‘options.message = ‘ + options.message);
    if (options.message) {
    options.message[‘splitdate’] = options.message;
    }
    });

    var dayVal, monthVal, yearVal;

    $.validator.addMethod(‘splitdate’, function(value, element, params) {
    //alert(‘in addFunction’);

    if (element.id.indexOf(params.monthFieldId) > 0)
    monthVal = value;
    if (element.id.indexOf(params.dayFieldId) > 0)
    dayVal = value;
    if (element.id.indexOf(params.yearFieldId) > 0)
    yearVal = value;

    return isValidDate(yearVal, monthVal, dayVal);
    });

    function isValidDate(y, m, d) {
    var date = new Date(y, m – 1, d);
    var convertedDate = “” + date.getFullYear() + (date.getMonth() + 1) + date.getDate();
    var givenDate = “” + y + m + d;
    return (givenDate == convertedDate);
    }

Leave a comment