Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MVC Editor Template for TimeSpan

I want to bind a set of text boxes, Hours, Minutes and Seconds to a TimeSpan in my model. I don't want to add Hours, Minutes and Seconds to my model and I'd rather not have them as parameters passed to the action method.

I've found some discussions on using Editor Templates, but can't find a good example.

like image 276
tom Avatar asked Mar 22 '23 06:03

tom


1 Answers

There are two ways to do this. The first way is to implement IModelBinder, which would require that you pass the TimeSpan as a separate parameter to your action and then set the model's TimeSpan property to it:

[HttpPost]
public ActionResult Index(YourViewModel model, 
    [ModelBinder(typeof(TimeSpanModelBinder))] TimeSpan ts)
{
    model.LengthOfTime = ts;

    // do stuff
}

The second is to derive from DefaultModelBinder, which then allows you to simply bind straight to your model:

[HttpPost]
public ActionResult Index([ModelBinder(typeof(TimeSpanModelBinder))] YourViewModel model)
{
    // do stuff
}

The advantage of implementing IModelBinder is that you don't need to define a custom attribute to mark your property with. You also don't need to dynamically look up the property on your model at runtime.

The advantage of deriving from DefaultModelBinder is, as long as you use the custom attribute, it will work for any model that you have, thus preserving consistency across your actions if you need to use this in several places.

First Approach: Implementing IModelBinder

For this approach, implementing IModelBinder simply means creating an implementation for the BindModel method:

public class TimeSpanModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext,
        ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType != typeof(TimeSpan))
            return null;

        int hours = 0, minutes = 0, seconds = 0;

        hours = ParseTimeComponent(HoursKey, bindingContext);
        minutes = ParseTimeComponent(MinutesKey, bindingContext);
        seconds = ParseTimeComponent(SecondsKey, bindingContext);

        return new TimeSpan(hours, minutes, seconds);
    }

    public int ParseTimeComponent(string component,
        ModelBindingContext bindingContext)
    {
        int result = 0;
        var val = bindingContext.ValueProvider.GetValue(component);

        if (!int.TryParse(val.AttemptedValue, out result))
            bindingContext.ModelState.AddModelError(component,
                String.Format("The field '{0}' is required.", component));

        // This is important
        bindingContext.ModelState.SetModelValue(component, val);

        return result;
    }

    private readonly string HoursKey = "Hours";
    private readonly string MinutesKey = "Minutes";
    private readonly string SecondsKey = "Seconds";
}

Notice the calls to bindingContext.ModelState.SetModelValue. This method makes sure your model is repopulated with the correct data in the event that it needs to be redisplayed on your form. This is important as it preserves the default functionality of filling in all of the form fields again if the data submitted by the user failed validation.

Second Approach: Deriving from DefaultModelBinder

The first thing you need to do is create a custom attribute, which you will use to mark the property of your model that you wish to bind to:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class TimeSpanComponentAttribute : Attribute
{
    public override bool Match(object obj)
    {
        return obj.GetType() == typeof(TimeSpan);
    }
}

You would then use this in your view model like so:

public class YourViewModel
{
    [Required]
    public string SomeRequiredProperty { get; set; }
    [TimeSpanComponent]
    public TimeSpan LengthOfTime { get; set; }
}

This then allows us to look for the property that is marked with that attribute in our custom model binder:

public class TimeSpanModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext,
        ModelBindingContext bindingContext)
    {
        var model = this.CreateModel(controllerContext,
            bindingContext, bindingContext.ModelType);
        bindingContext.ModelMetadata.Model = model;

        var target = model.GetType()
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => Attribute.IsDefined(p, typeof(TimeSpanComponentAttribute)))
            .Single();

        if (target == null)
            throw new MemberAccessException(PropertyNotFound);

        int hours = 0, minutes = 0, seconds = 0;

        hours = ParseTimeComponent(HoursKey, bindingContext);
        minutes = ParseTimeComponent(MinutesKey, bindingContext);
        seconds = ParseTimeComponent(SecondsKey, bindingContext);

        target.SetValue(model, new TimeSpan(hours, minutes, seconds));

        return base.BindModel(controllerContext, bindingContext);
    }

    public int ParseTimeComponent(string component,
        ModelBindingContext bindingContext)
    {
        int result = 0;
        var val = bindingContext.ValueProvider.GetValue(component);

        if (!int.TryParse(val.AttemptedValue, out result))
            bindingContext.ModelState.AddModelError(component,
                String.Format("The field '{0}' is required.", component));

        // Again, this is important
        bindingContext.ModelState.SetModelValue(component, val);

        return result;
    }

    private readonly string HoursKey = "Hours";
    private readonly string MinutesKey = "Minutes";
    private readonly string SecondsKey = "Seconds";
    private readonly string PropertyNotFound = "Could not bind to TimeSpan property.  Did you forget to decorate " +
                                               "a property with TimeSpanComponentAttribute?";
}

Notice how in BindModel we're locating the correct property based on the custom attribute. Also, after we've finished binding to the property, the call to the base version of BindModel allows the default model binder to take care of everything else.

Note

Both approaches assume the names of your textboxes would be Hours, Minutes and Seconds, respectively. If they're not, just change the values of the 3 private strings.

I changed a lot with this edit, so let me know if I've missed something.

Thanks for asking this question - I've learned a lot from it.

like image 156
John H Avatar answered Apr 01 '23 01:04

John H