Splitting DateTime - Unit Testing ASP.NET MVC Custom Model Binders
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?
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
I mean, I realize it's over the top for one instance, but I'm thinking of asking the MVC team to put it in the MVC Futures stuff for a future release. Is it generic enough and useful enough that you'd use it if you knew it existed?
I would use it, if will be part of MVC. It's very handy.
I had nearly the same problem on one of my projects. Need to split only date to Listboxes like this "MM / YYYY" and "DD".
I just use hidden field into wchich is date formated by JQuery.DatePicker on listbox changes. My be lame, but it works perfect.
Franta.
I can't send you email cause registration for i-names is not working so I'm writting here :)
I've found your notepad2 with Ruby syntax built in. Do you know that notepad2 have syntax export/import in the newest version?
Maybe you can create just syntax file for Ruby to import into notepad2? Instead of special builds which you have tu update?
Still great idea to have Ruby in notepad2 :)
Greetings,
Ania
One other gimmick I like to add to my date uis is support for keywords like 'Today', 'Tomorrow', 'Yesterday', etc. This would be a good way to support that...
Greetings,
Ruddy
Sounds like you are creating presentation models that aggregate many "business logic" models together for the view. Be interesting to see a post on this with your solution.
I really like this approach. I would definitely use this, so long as it worked with other input types like selects and the upcoming date, datetime, time, etc. elements in HTML 5.
Great job!
This problem CAN be solved in a single line, just pulling the two DateTime fields out of the Request object itself. Blame me, not MVC.
I assume you have some framework that automates form building and validation, and plugs into models to CRUD, but you wanted the form fields to be different from database fields to make for a better web GUI.
I think you'd be better off with one field and the Javascript date picker. If you don't want Javascript (I wouldn't blame you), you could try making the field input parser "smarter" to accept values like "9pm today", but that really depends on what the particular interface is used for etc.
By the way, what do you do if the form has to include a field that don't make sense to keep the value of in the database, i.e. the "I agree to the rules I didn't read" checkbox?
public ActionResult Create(FormCollection collection,
[DateAndTime("PublicationDate.Year", "PublicationDate.Month", "PublicationDate.Day", null, null, null)]
DateTime PublicationDate)
I get an exception whenever the user leaves one of the three day, month and year fields empty. Unfortunately, the BindModel method is called before I can check for empty fields in my controller, because it is triggered by the Attribute on the PublicationDate parameter. Did I do something wrong?
I did find a (temporary?) solution by adjusting the following fragment (did the same for the "time part only"):
if (GetA<int>(bindingContext, this.Year) != null &&
GetA<int>(bindingContext, this.Month) != null &&
GetA<int>(bindingContext, this.Day) != null)
{
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);
}
and by adding the following else if statement at the bottom:
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);
}
else if (dateAttempt == null && timeAttempt == null)
{
return DateTime.MinValue;
}
//Only got one half? Return as much as we have!
return dateAttempt ?? timeAttempt;
}
I can then add a line of code to my controller that checks if the PublicationDate is not DateTime.MinValue. If it is, I call ModelState.AddError. Would you say that this is a valid solution?
Regards,
Inge
The fact is that you didn't catch the exception when calling ConvertTo.
I have modify the GetA<T> function to be :
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...
string modelName = bindingContext.ModelName + "." + key;
bindingContext.ValueProvider.TryGetValue(modelName, out valueResult);
//Didn't work? Try without the prefix if needed...
if (valueResult == null && bindingContext.FallbackToEmptyPrefix == true)
{
modelName = key;
bindingContext.ValueProvider.TryGetValue(key, out valueResult);
}
if (valueResult == null)
{
return null;
}
// Add the value to the model state, without this line, a null reference exception is thrown
// when redisplaying the form with a not convertible value
bindingContext.ModelState.SetModelValue(modelName, valueResult);
try
{
return (Nullable<T>)valueResult.ConvertTo(typeof(T));
}
catch(Exception ex)
{
bindingContext.ModelState.AddModelError(modelName, ex);
return null;
}
}
my 2 cents
The domain model has an Order with ShipDate property. (I'll leave aside the fact that ShipDate is a DateTime since for some odd reason .Net doesn't have a simpler Date type...)
The designers and HTML coders made an order form where the ShipDate is made of 3 drop downs: day/month/year.
Are you suggesting I use a similar model binder to instantiate the Order ShipDate property from the 3 drop downs?
I challenge anyone at Microsoft that has anything remotely close to do with MVC to make a simple HTML form and domain object as I describe above and give it an EQUALLY SIMPLE IMPLEMENTATION using the provided MVC mechanics.
You will all FAIL, and that is *extremely* upsetting.
public void CreateOrder(Order order, int month, int day, int year)
then just make a date time, new DateTime(month, day, year).
I just made the model binder to hide a lot of *generic* complexity. The *specific* problem is easy to solve.
Hope this helps.
Comments are closed.
I know you thought of it, but why not use the Date and Time data types in SQL 2008 and split it into two fields to begin with?