I was fascinated by this question, and I spent a fair amount of time thinking about your goals. I had a breakthrough yesterday, and I have some code which accomplishes almost all of your objectives.
You had said that you wanted the validators to fire only when Changed
was checked. This code always fires the validators, as I do not believe it is a good practice to prevent validators from firing. What the code does instead is to check to see if the user has changed the value, and it automatically checks Changed
when this happens. If the user unchecks the Changed checkbox, the old value is placed in the Value
box.
The code consists of an HTML helper, a ModelMetadataProvider, a ModelBinder, and just a little javascript. Before the code, here is the defined model, which is the same as Darin's, with one additional property added:
public interface IChangeable
{
bool Changed { get; set; }
}
public class Changeable<T> : IChangeable
{
public bool Changed { get; set; }
public T Value { get; set; }
}
public class MyModel
{
[Range(1, 10), Display(Name = "Some Integer")]
public Changeable<int> SomeInt { get; set; }
[StringLength(32, MinimumLength = 6), Display(Name = "This String")]
public Changeable<string> TheString { get; set; }
}
Starting with the HTML helper:
public static class HtmlHelperExtensions
{
public static MvcHtmlString ChangeableFor<TModel, TValue, TType>(this HtmlHelper<TModel> html, Expression<Func<TModel, TValue>> expression, Changeable<TType> changeable)
{
var name = ExpressionHelper.GetExpressionText(expression);
if (String.IsNullOrEmpty(name))
throw new ArgumentNullException("name", "Name cannot be null");
var metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
var type = metadata.ModelType;
var containerType = metadata.ContainerType;
var arg = Expression.Parameter(containerType, "x");
Expression expr = arg;
expr = Expression.Property(expr, name);
expr = Expression.Property(expr, "Value");
var funcExpr = Expression.Lambda(expr, arg) as Expression<Func<TModel, TType>>;
var valueModelMetadata = ModelMetadata.FromLambdaExpression(funcExpr, html.ViewData);
Expression exprChanged = arg;
exprChanged = Expression.Property(exprChanged, name);
exprChanged = Expression.Property(exprChanged, "Changed");
var funcExprChanged = Expression.Lambda(exprChanged, arg) as Expression<Func<TModel, bool>>;
var htmlSb = new StringBuilder("\n");
htmlSb.Append(LabelExtensions.Label(html, metadata.GetDisplayName()));
htmlSb.Append("<br />\n");
htmlSb.Append(InputExtensions.CheckBoxFor(html, funcExprChanged));
htmlSb.Append(" Changed<br />\n");
htmlSb.Append(InputExtensions.Hidden(html, name + ".OldValue", valueModelMetadata.Model) + "\n");
htmlSb.Append(EditorExtensions.EditorFor(html, funcExpr, new KeyValuePair<string, object>("parentMetadata", metadata)));
htmlSb.Append(ValidationExtensions.ValidationMessageFor(html, funcExpr));
htmlSb.Append("<br />\n");
return new MvcHtmlString(htmlSb.ToString());
}
}
This passes the parent metadata into the ViewData
(which will permit us to get the class validators later on). It also creates lambda expressions so we can use CheckBoxFor()
and EditorFor()
. The view using our model and this helper is as follows:
@model MyModel
@{
ViewBag.Title = "Index";
Layout = "~/Views/Shared/_Layout.cshtml";
}
@using (Html.BeginForm())
{
<script type="text/javascript">
$(document).ready(function () {
$("input[id$='Value']").live("keyup blur", function () {
var prefix = this.id.split("_")[0];
var oldValue = $("#" + prefix + "_OldValue").val();
var changed = oldValue != $(this).val()
$("#" + prefix + "_Changed").attr("checked", changed);
if (changed) {
// validate
$(this.form).validate().element($("#" + prefix + "_Value")[0]);
}
});
$("input[id$='Changed']").live("click", function () {
if (!this.checked) {
// replace value with old value
var prefix = this.id.split("_")[0];
var oldValue = $("#" + prefix + "_OldValue").val();
$("#" + prefix + "_Value").val(oldValue);
// validate
$(this.form).validate().element($("#" + prefix + "_Value")[0]);
}
});
});
</script>
@Html.ChangeableFor(x => x.SomeInt, Model.SomeInt)
@Html.ChangeableFor(x => x.TheString, Model.TheString)
<input type="submit" value="Submit" />
}
Note the javascript for dealing with changes to the Value textbox and clicks on the Changed checkbox. Also note the need to pass the Changeable<T>
property twice to the ChangeableFor()
helper.
Next, the custom ModelValidatorProvider:
public class MyDataAnnotationsModelValidatorProvider : DataAnnotationsModelValidatorProvider
{
private bool _provideParentValidators = false;
protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
{
if (metadata.ContainerType != null && metadata.ContainerType.Name.IndexOf("Changeable") > -1 && metadata.PropertyName == "Value")
{
var viewContext = context as ViewContext;
if (viewContext != null)
{
var viewData = viewContext.ViewData;
var index = viewData.Keys.ToList().IndexOf("Value");
var parentMetadata = viewData.Values.ToList()[index] as ModelMetadata;
_provideParentValidators = true;
var vals = base.GetValidators(parentMetadata, context);
_provideParentValidators = false;
return vals;
}
else
{
var viewData = context.Controller.ViewData;
var keyName = viewData.ModelState.Keys.ToList().Last().Split(new string[] { "." }, StringSplitOptions.None).First();
var index = viewData.Keys.ToList().IndexOf(keyName);
var parentMetadata = viewData.Values.ToList()[index] as ModelMetadata;
parentMetadata.Model = metadata.Model;
_provideParentValidators = true;
var vals = base.GetValidators(parentMetadata, context);
_provideParentValidators = false;
return vals;
}
}
else if (metadata.ModelType.Name.IndexOf("Changeable") > -1 && !_provideParentValidators)
{
// DO NOT provide parent's validators, unless it is at the request of the child Value property
return new List<ModelValidator>();
}
return base.GetValidators(metadata, context, attributes).ToList();
}
}
Note that there are different means of checking for the parent metadata, depending on whether we are populating a view or binding a model on a POST. Also note that we need to suppress the parent from receiving the validators.
Finally, the ModelBinder:
public class ChangeableModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (controllerContext.Controller.ViewData.Keys.ToList().IndexOf(bindingContext.ModelName) < 0)
controllerContext.Controller.ViewData.Add(bindingContext.ModelName, bindingContext.ModelMetadata);
return base.BindModel(controllerContext, bindingContext);
}
}
This takes the parent metadata, and stashes it away, to be accessed later in the custom ModelValidatorProvider.
Finish up with the following in Application_Start
in Global.asax.cs:
ModelValidatorProviders.Providers.Clear();
ModelValidatorProviders.Providers.Add(new MvcApplication5.Extensions.MyDataAnnotationsModelValidatorProvider());
MyDataAnnotationsModelValidatorProvider.AddImplicitRequiredAttributeForValueTypes = false;
ModelBinders.Binders.Add(typeof(MvcApplication5.Controllers.Changeable<int>), new ChangeableModelBinder());
ModelBinders.Binders.Add(typeof(MvcApplication5.Controllers.Changeable<string>), new ChangeableModelBinder());
// you must add a ModelBinders.Binders.Add() declaration for each type T you
// will use in your Changeable<T>
Viola!