Generic TimeSpan binding in Asp.NET MVC 2
Asked Answered
T

3

9

I have an input form that is bound to a model. The model has a TimeSpan property, but it only gets the value correctly if I enter the time as hh:mm or hh:mm:ss. What I want is for it to capture the value even if it's written as hhmm or hh.mm or hh.mm.ss or ... I want many different formats to be parsed correctly. Is this possible?

Thanks!

Timmi answered 30/9, 2010 at 15:32 Comment(0)
F
4

Yes - write a custom model binder for your model object. There's an thread about just that subject here on SO: ASP.NET MVC2 - Custom Model Binder Examples

Fauch answered 30/9, 2010 at 15:34 Comment(0)
C
22

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 ifs.
  • 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.

Corneous answered 8/5, 2011 at 20:49 Comment(11)
And with tests too? Brilliant. Thanks!Allantois
@Ted, you're welcome. Sometimes tests make the best documentation.Corneous
More than sometimes, because among many other things, we usually keep our tests up-to-date while there's no guarantee the documentation will be. If the tests work, then the "real documentation" is correct.Allantois
This doesn't seem to work in MVC 5 because the BindProperty method never gets called.Apatetic
@Mike, most likely you are binding to the wrong type. It should be something like this in Global.asax.cs : ModelBinders.Binders.Add(typeof(<YOUR_MODEL_HERE>), new TimeSpanModelBinder());Royalroyalist
@DrewNoakes, Resharper says this line propertyDescriptor.PropertyType.Equals(typeof(TimeSpan?)) should have been propertyDescriptor.PropertyType == (typeof(TimeSpan?)) instead.Royalroyalist
@RosdiKasim, does it say why? The code works as shown. It may be that == is not overloaded for System.Type and so R# says you can use the more natural operator syntax. I can't think of a reason to change it beyond that of style.Corneous
I think this question answers it, #13648308Royalroyalist
@RosdiKasim Are you supposed to bind the TimeSpan or the MyViewModel in ModelBinders.Binders.Add(typeof(<YOUR_MODEL_HERE>), new TimeSpanModelBinder())? The examples all show binding TimeSpan.Hilleary
@Hilleary In this case it should be TimeSpan, now I couldn't think of reason why I suggested YOUR_MODEL_HERE. Just remember if you bind TimeSpan you might want to bind TimeSpan? too... depends on your use case of course.Royalroyalist
@Hilleary @Apatetic If you want bind property to get called you need this ModelBinders.Binders.Add(typeof(<YOUR_MODEL_HERE>), new TimeSpanModelBinder()) to Global.asax.cs. If you want this to work for all TimeSpans then you need to change the code to override BindModel instead of BindProperty then add ModelBinders.Binders.Add(typeof(TimeSpan), new TimeSpanModelBinder()); to Global.asax.csSaratov
F
4

Yes - write a custom model binder for your model object. There's an thread about just that subject here on SO: ASP.NET MVC2 - Custom Model Binder Examples

Fauch answered 30/9, 2010 at 15:34 Comment(0)
T
3

For the record, here's how I did it:

using System;
using System.Globalization;
using System.Web.Mvc;

namespace Utils.ModelBinders
{
    public class CustomTimeSpanModelBinder : DefaultModelBinder
    {
        protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, System.ComponentModel.PropertyDescriptor propertyDescriptor)
        {
            var form = controllerContext.HttpContext.Request.Form;

            if (propertyDescriptor.PropertyType.Equals(typeof(TimeSpan?)))
            {
                var text = form[propertyDescriptor.Name];
                DateTime value;
                if (DateTime.TryParseExact(text, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out value))
                        SetProperty(controllerContext,bindingContext,propertyDescriptor,value.TimeOfDay);
                else if (DateTime.TryParseExact(text, "HH.mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out value))
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, value.TimeOfDay);
                else if (DateTime.TryParseExact(text, "HHmm", CultureInfo.InvariantCulture, DateTimeStyles.None, out value))
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, value.TimeOfDay);
                else if (DateTime.TryParseExact(text, "HH,mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out value))
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, value.TimeOfDay);
                else if (DateTime.TryParseExact(text, "HH", CultureInfo.InvariantCulture, DateTimeStyles.None, out value))
                    SetProperty(controllerContext, bindingContext, propertyDescriptor, value.TimeOfDay);
            }
            else
            {
                base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
            }
        }
    }
}
Timmi answered 1/10, 2010 at 8:3 Comment(3)
Thanks for sharing this code. Could you also show how you specified it to be used? Can you configure this just for a single property on a particular model, or does it apply globally to all TimeSpan property binding operations?Corneous
I added this to the Application_Start method of the Global_asax.cs file: ModelBinders.Binders.DefaultBinder = new CustomTimeSpanModelBinder(); I think you can also specify this on an action by action basis using annotations.Timmi
Thanks Carles. I've posted my adaptation of your code as an answer too. In my case I wanted to handle as many different time-of-day strings as could reasonably be provided and interpreted.Corneous

© 2022 - 2024 — McMap. All rights reserved.