Validating and editing a “Changeable”/optional typ

2019-08-17 16:18发布

问题:

for a project, I need a way to specify whether or not a property has changed.

So in a model I would like the following:

public class Changeable<T>
{
   public bool Changed { get; set; }
   public T Value { get; set; }
}

public class MyModel
{
    [Range(1, 10)]
    public Changeable<int> SomeInt { get; set; }
}

View:

@Html.EditorFor(model => model.SomeInt)

And then I would generate a editor with a textfield (the int) and a checkbox (is changed). The validation attributes (Range etc.) should be invoked when the checkbox is checked but not when it is unchecked.

I have tried to make the above with an editortemplate for Changeable (and then comes the validation, model binding etc.) but I'm already lost by the editortemplate because it can't be generic.

Is the solution I want possible, or is there another reasonalble elegant solution?

Right now I'm developing a solution with a property string[] ChangedProperties and a lot of Javascript to handle validation etc. but it's rather ugly and far from done.

Thanks...

回答1:

You may try using dynamic types with a custom range validation attribute:

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
{
    [MyRange(1, 10)]
    public Changeable<int> SomeInt { get; set; }
}

public class MyRangeAttribute : RangeAttribute
{
    public MyRangeAttribute(double minimum, double maximum): base(minimum, maximum)
    { }

    public MyRangeAttribute(int minimum, int maximum)
        : base(minimum, maximum)
    { }

    public override bool IsValid(object value)
    {
        var changeable = value as IChangeable;
        if (changeable == null || !changeable.Changed)
        {
            return true;
        }
        dynamic dynValue = value;

        return base.IsValid((object)dynValue.Value);
    }
}

then a controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View(new MyModel
        {
            SomeInt = new Changeable<int>
            {
                Changed = true,
                Value = 5
            }
        });
    }

    [HttpPost]
    public ActionResult Index(MyModel model)
    {
        return View(model);
    }
}

then a view (~/Views/Home/Index.cshtml):

@model MyModel

@using (Html.BeginForm())
{
    @Html.EditorFor(x => x.SomeInt)
    <button type="submit">OK</button>
}

and the corresponding editor template (notice the name of the editor template file)

~/Views/Shared/EditorTemplates/Changeable`1.cshtml

@model dynamic
@Html.CheckBox("Changed", (bool)Model.Changed)
@Html.Editor("Value")
@Html.ValidationMessage("")


回答2:

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!



回答3:

I'm not 100% sure, but I think what you want is:

private int _changeable;

public Changeable<int> SomeInt { 
get 
    { return _changeable } 
set 
    { _changeable = value;
      Changed = true;
    }
}