I've got this form for users to create an event. One of the fields is a DateTime, like this:
And that's kind of lame as it's hard to type in a Date and a Time at the same time. It's also no fun. Most sites would have those separated, and ideally use some kind of Calendar Picker with jQuery or something.
But when you post a form like this back to a Controller Action, it doesn't exactly line up neatly with a System.DateTime object. There's no clean way to get partials like this and combine them into a single DateTime. The "ViewModel" in doesn't match the Model itself. I could certainly make my method take two DateTimes, along with the other fields, then put them together later. It could get mess though, if I split things up even more, like some travel sites do with Month, Date, Year each in separate boxes, then Hours, Minutes, and Seconds off in their own.
I figured this might be a decent place for a custom "DateAndTimeModelBinder" after my last attempt at a Model Binder was near-universally panned. ;)
Here's my thoughts, and I'm interested in your thoughts as well, Dear Reader.
DateAndTimeModelBinder
First, usage. You can either put this Custom Model Binder in charge of all your DateTimes by registering it in the Global.asax:
ModelBinders.Binders[typeof(DateTime)] =
new DateAndTimeModelBinder() { Date = "Date", Time = "Time" };
The strings there are the suffixes of the fields in your View that will be holding the Date and the Time. There are other options in there like Hour, Minute, you get the idea.
Instead of my View having a date in one field:
<label for="EventDate">Event Date:</label>
<%= Html.TextBox("EventDate", Model.Dinner.EventDate) %>
I split it up, and add my chosen suffixes:
<label for="EventDate">Event Date:</label>
<%= Html.TextBox("EventDate.Date", Model.Dinner.EventDate.ToShortDateString()) %>
<%= Html.TextBox("EventDate.Time", Model.Dinner.EventDate.ToShortTimeString()) %>
Now, when the Form is POST'ed back, no one is the wiser, and the model is unchanged:
[AcceptVerbs(HttpVerbs.Post), Authorize]
public ActionResult Create(Dinner dinnerToCreate) {
//The two fields are now inside dinnerCreate.EventDate
// and model validation runs as before...
}
That's the general idea. You can also just put the attribute on a specific parameter, like this:
public ActionResult Edit(int id,
[DateAndTime("year", "mo", "day", "hh","mm","secondsorhwatever")]
DateTime foo) {
...yada yada yada...
}
It's so nice, that I give it the Works On My Machine Seal:

Here's the code, so far. It's longish. I'm interested in your opinions on how to make it clearer, cleaner and DRYer (without breaking the tests!)
NOTE: If you're reading this via RSS, the code will be syntax highlighted and easier to read if you visit this post on my site directly.
public class DateAndTimeModelBinder : IModelBinder
{
public DateAndTimeModelBinder() { }
public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException("bindingContext");
}
//Maybe we're lucky and they just want a DateTime the regular way.
DateTime? dateTimeAttempt = GetA<DateTime>(bindingContext, "DateTime");
if (dateTimeAttempt != null)
{
return dateTimeAttempt.Value;
}
//If they haven't set Month,Day,Year OR Date, set "date" and get ready for an attempt
if (this.MonthDayYearSet == false && this.DateSet == false)
{
this.Date = "Date";
}
//If they haven't set Hour, Minute, Second OR Time, set "time" and get ready for an attempt
if (this.HourMinuteSecondSet == false && this.TimeSet == false)
{
this.Time = "Time";
}
//Did they want the Date *and* Time?
DateTime? dateAttempt = GetA<DateTime>(bindingContext, this.Date);
DateTime? timeAttempt = GetA<DateTime>(bindingContext, this.Time);
//Maybe they wanted the Time via parts
if (this.HourMinuteSecondSet)
{
timeAttempt = new DateTime(
DateTime.MinValue.Year, DateTime.MinValue.Month, DateTime.MinValue.Day,
GetA<int>(bindingContext, this.Hour).Value,
GetA<int>(bindingContext, this.Minute).Value,
GetA<int>(bindingContext, this.Second).Value);
}
//Maybe they wanted the Date via parts
if (this.MonthDayYearSet)
{
dateAttempt = new DateTime(
GetA<int>(bindingContext, this.Year).Value,
GetA<int>(bindingContext, this.Month).Value,
GetA<int>(bindingContext, this.Day).Value,
DateTime.MinValue.Hour, DateTime.MinValue.Minute, DateTime.MinValue.Second);
}
//If we got both parts, assemble them!
if (dateAttempt != null && timeAttempt != null)
{
return new DateTime(dateAttempt.Value.Year,
dateAttempt.Value.Month,
dateAttempt.Value.Day,
timeAttempt.Value.Hour,
timeAttempt.Value.Minute,
timeAttempt.Value.Second);
}
//Only got one half? Return as much as we have!
return dateAttempt ?? timeAttempt;
}
private Nullable<T> GetA<T>(ModelBindingContext bindingContext, string key) where T : struct
{
if (String.IsNullOrEmpty(key)) return null;
ValueProviderResult valueResult;
//Try it with the prefix...
bindingContext.ValueProvider.TryGetValue(bindingContext.ModelName + "." + key, out valueResult);
//Didn't work? Try without the prefix if needed...
if (valueResult == null && bindingContext.FallbackToEmptyPrefix == true)
{
bindingContext.ValueProvider.TryGetValue(key, out valueResult);
}
if (valueResult == null)
{
return null;
}
return (Nullable<T>)valueResult.ConvertTo(typeof(T));
}
public string Date { get; set; }
public string Time { get; set; }
public string Month { get; set; }
public string Day { get; set; }
public string Year { get; set; }
public string Hour { get; set; }
public string Minute { get; set; }
public string Second { get; set; }
public bool DateSet { get { return !String.IsNullOrEmpty(Date); } }
public bool MonthDayYearSet { get { return !(String.IsNullOrEmpty(Month) && String.IsNullOrEmpty(Day) && String.IsNullOrEmpty(Year)); } }
public bool TimeSet { get { return !String.IsNullOrEmpty(Time); } }
public bool HourMinuteSecondSet { get { return !(String.IsNullOrEmpty(Hour) && String.IsNullOrEmpty(Minute) && String.IsNullOrEmpty(Second)); } }
}
public class DateAndTimeAttribute : CustomModelBinderAttribute
{
private IModelBinder _binder;
// The user cares about a full date structure and full
// time structure, or one or the other.
public DateAndTimeAttribute(string date, string time)
{
_binder = new DateAndTimeModelBinder
{
Date = date,
Time = time
};
}
// The user wants to capture the date and time (or only one)
// as individual portions.
public DateAndTimeAttribute(string year, string month, string day,
string hour, string minute, string second)
{
_binder = new DateAndTimeModelBinder
{
Day = day,
Month = month,
Year = year,
Hour = hour,
Minute = minute,
Second = second
};
}
// The user wants to capture the date and time (or only one)
// as individual portions.
public DateAndTimeAttribute(string date, string time,
string year, string month, string day,
string hour, string minute, string second)
{
_binder = new DateAndTimeModelBinder
{
Day = day,
Month = month,
Year = year,
Hour = hour,
Minute = minute,
Second = second,
Date = date,
Time = time
};
}
public override IModelBinder GetBinder() { return _binder; }
}
Testing the Custom Model Binder
It works for Dates or Times, also. If you just want a Time, you'll get a MinDate, and if you just want a Date, you'll get a Date at midnight.
Here's just two of the tests. Note I was able to test this custom Model Binder without any mocking (thanks Phil!)
Some custom model binders do require mocking if they go digging around in the HttpContext or other concrete places. In this case, I just needed to poke around in the Form, so it was cleaner to use the existing ValueProvider.
[TestMethod]
public void Date_Can_Be_Pulled_Via_Provided_Month_Day_Year()
{
var dict = new ValueProviderDictionary(null) {
{ "foo.month", new ValueProviderResult("2","2",null) },
{ "foo.day", new ValueProviderResult("12", "12", null) },
{ "foo.year", new ValueProviderResult("1964", "1964", null) }
};
var bindingContext = new ModelBindingContext() { ModelName = "foo", ValueProvider = dict};
DateAndTimeModelBinder b = new DateAndTimeModelBinder() { Month = "month", Day = "day", Year = "year" };
DateTime result = (DateTime)b.BindModel(null, bindingContext);
Assert.AreEqual(DateTime.Parse("1964-02-12 12:00:00 am"), result);
}
[TestMethod]
public void DateTime_Can_Be_Pulled_Via_Provided_Month_Day_Year_Hour_Minute_Second_Alternate_Names()
{
var dict = new ValueProviderDictionary(null) {
{ "foo.month1", new ValueProviderResult("2","2",null) },
{ "foo.day1", new ValueProviderResult("12", "12", null) },
{ "foo.year1", new ValueProviderResult("1964", "1964", null) },
{ "foo.hour1", new ValueProviderResult("13","13",null) },
{ "foo.minute1", new ValueProviderResult("44", "44", null) },
{ "foo.second1", new ValueProviderResult("01", "01", null) }
};
var bindingContext = new ModelBindingContext() { ModelName = "foo", ValueProvider = dict };
DateAndTimeModelBinder b = new DateAndTimeModelBinder() { Month = "month1", Day = "day1", Year = "year1", Hour = "hour1", Minute = "minute1", Second = "second1" };
DateTime result = (DateTime)b.BindModel(null, bindingContext);
Assert.AreEqual(DateTime.Parse("1964-02-12 13:44:01"), result);
}
Thanks to LeviB for his help. Your thoughts?
Hosting By