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.
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.
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.
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.
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With