I added a few enhancements to Carles' code and wanted to share them here in case they're useful for others.
- Ensure that if no patterns successfully parse the time, then still call the base in order to show a validation error (otherwise the value is left as
TimeSpan.Zero
and no validation error raised.)
- Use a loop rather than chained
if
s.
- Support the use of
AM
and PM
suffices.
- Ignore whitespace.
Here's the code:
public sealed class TimeSpanModelBinder : DefaultModelBinder
{
private const DateTimeStyles _dateTimeStyles = DateTimeStyles.AllowWhiteSpaces | DateTimeStyles.AssumeLocal | DateTimeStyles.NoCurrentDateDefault;
protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
{
var form = controllerContext.HttpContext.Request.Form;
if (propertyDescriptor.PropertyType.Equals(typeof(TimeSpan?)) || propertyDescriptor.PropertyType.Equals(typeof(TimeSpan)))
{
var text = form[propertyDescriptor.Name];
TimeSpan time;
if (text != null && TryParseTime(text, out time))
{
SetProperty(controllerContext, bindingContext, propertyDescriptor, time);
return;
}
}
// Either a different type, or we couldn't parse the string.
base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
}
public static bool TryParseTime(string text, out TimeSpan time)
{
if (text == null)
throw new ArgumentNullException("text");
var formats = new[] {
"HH:mm", "HH.mm", "HHmm", "HH,mm", "HH",
"H:mm", "H.mm", "H,mm",
"hh:mmtt", "hh.mmtt", "hhmmtt", "hh,mmtt", "hhtt",
"h:mmtt", "h.mmtt", "hmmtt", "h,mmtt", "htt"
};
text = Regex.Replace(text, "([^0-9]|^)([0-9])([0-9]{2})([^0-9]|$)", "$1$2:$3$4");
text = Regex.Replace(text, "^[0-9]$", "0$0");
foreach (var format in formats)
{
DateTime value;
if (DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, _dateTimeStyles, out value))
{
time = value.TimeOfDay;
return true;
}
}
time = TimeSpan.Zero;
return false;
}
}
This may seem a little over the top, but I want my users to be able to enter pretty much anything and have my app work it out.
It can be applied to all DateTime
instances via this code in Global.asax.cs
:
ModelBinders.Binders.Add(typeof(TimeSpan), new TimeSpanModelBinder());
Or just on a specific action method parameter:
public ActionResult Save([ModelBinder(typeof(TimeSpanModelBinder))] MyModel model)
{ ... }
And here's a simple unit test just to validate some potential inputs/outputs:
[TestMethod]
public void TimeSpanParsing()
{
var testData = new[] {
new { Text = "100", Time = new TimeSpan(1, 0, 0) },
new { Text = "10:00 PM", Time = new TimeSpan(22, 0, 0) },
new { Text = "2", Time = new TimeSpan(2, 0, 0) },
new { Text = "10", Time = new TimeSpan(10, 0, 0) },
new { Text = "100PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "1000", Time = new TimeSpan(10, 0, 0) },
new { Text = "10:00", Time = new TimeSpan(10, 0, 0) },
new { Text = "10.00", Time = new TimeSpan(10, 0, 0) },
new { Text = "13:00", Time = new TimeSpan(13, 0, 0) },
new { Text = "13.00", Time = new TimeSpan(13, 0, 0) },
new { Text = "10 PM", Time = new TimeSpan(22, 0, 0) },
new { Text = " 10\t PM ", Time = new TimeSpan(22, 0, 0) },
new { Text = "10PM", Time = new TimeSpan(22, 0, 0) },
new { Text = "1PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "1 am", Time = new TimeSpan(1, 0, 0) },
new { Text = "1 AM", Time = new TimeSpan(1, 0, 0) },
new { Text = "1 pm", Time = new TimeSpan(13, 0, 0) },
new { Text = "1 PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "01 PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "0100 PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "01.00 PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "01.00PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "1:00PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "1:00 PM", Time = new TimeSpan(13, 0, 0) },
new { Text = "12,34", Time = new TimeSpan(12, 34, 0) },
new { Text = "1012PM", Time = new TimeSpan(22, 12, 0) },
};
foreach (var test in testData)
{
try
{
TimeSpan time;
Assert.IsTrue(TimeSpanModelBinder.TryParseTime(test.Text, out time), "Should parse {0}", test.Text);
if (!Equals(time, test.Time))
Assert.Fail("Time parse failed. Expected {0} but got {1}", test.Time, time);
}
catch (FormatException)
{
Assert.Fail("Received format exception with text {0}", test.Text);
}
}
}
Hope that helps someone out.