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!

Drew Noakes
  • 300,895
  • 165
  • 679
  • 742
Carles Company
  • 7,118
  • 5
  • 49
  • 75

3 Answers3

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.

Drew Noakes
  • 300,895
  • 165
  • 679
  • 742
  • And with tests too? Brilliant. Thanks! – Ted Nov 14 '13 at 06:35
  • @Ted, you're welcome. Sometimes tests make the best documentation. – Drew Noakes Nov 14 '13 at 17:10
  • 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. – Ted Nov 14 '13 at 20:20
  • 1
    This doesn't seem to work in MVC 5 because the BindProperty method never gets called. – Mike Dec 23 '13 at 00:00
  • @Mike, most likely you are binding to the wrong type. It should be something like this in `Global.asax.cs` : `ModelBinders.Binders.Add(typeof(), new TimeSpanModelBinder());` – Rosdi Kasim Mar 01 '14 at 05:51
  • @DrewNoakes, Resharper says this line `propertyDescriptor.PropertyType.Equals(typeof(TimeSpan?))` should have been `propertyDescriptor.PropertyType == (typeof(TimeSpan?))` instead. – Rosdi Kasim Mar 01 '14 at 05:53
  • @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. – Drew Noakes Mar 02 '14 at 16:27
  • I think this question answers it, http://stackoverflow.com/questions/13647808/resharper-suggestion-check-for-reference-equality-instead – Rosdi Kasim Mar 04 '14 at 21:42
  • @RosdiKasim Are you supposed to bind the `TimeSpan` or the `MyViewModel` in `ModelBinders.Binders.Add(typeof(), new TimeSpanModelBinder())`? The examples all show binding `TimeSpan`. – Jack Apr 09 '18 at 23:57
  • @Jack 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. – Rosdi Kasim Apr 10 '18 at 01:59
  • @Jack @Mike If you want bind property to get called you need this `ModelBinders.Binders.Add(typeof(), 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.cs – odyth Jun 04 '19 at 19:34
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

Community
  • 1
  • 1
Lazarus
  • 41,906
  • 4
  • 43
  • 54
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);
            }
        }
    }
}
Carles Company
  • 7,118
  • 5
  • 49
  • 75
  • 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? – Drew Noakes May 07 '11 at 19:25
  • 1
    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. – Carles Company May 08 '11 at 08:44
  • 1
    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. – Drew Noakes May 08 '11 at 20:50