Data validation for every item in a list of my ViewModel
Asked Answered
P

3

6

To make a validation with a Regex, I usually do:

// In my ViewModel
[RegularExpression("MyRegex", ErrorMessageResourceName = "MyErrorMessage")]
public string MyField { get; set; }

And the HTML helper

@Html.TextBoxFor(model => model.MyField)

generates a markup that looks like this:

<input type="text" class="valid" name="MyField" value="" id="MyField" data-val="true" data-val-regex-pattern="MyRegex" data-val-regex="MyErrorMessage"></input>

The problem is that I want to have a dynamic number of fields and am now using

// In my ViewModel
[RegularExpression("MyRegex", ErrorMessageResourceName = "MyErrorMessage")]
public IList<string> MyField { get; set; }

This time

@Html.TextBoxFor(model => model.MyField[0])

will generate (without the regex html attributes)

<input id="MyField_0_" type="text" value="" name="MyField[0]"></input>

How can I ensure that data-val html attributes are created when binding elements of a list that has a DataAnnotation validation attribute in my ViewModel?

Plicate answered 28/4, 2014 at 9:19 Comment(2)
I think your HTML markup should have data-val-regex = "MyErrorMessage", and not data-val-required = "MyErrorMessage", since you don't have a [Required] annotation on your property.Humdrum
@AmateurProgrammer Copy/paste fail I assume. Edited.Plicate
M
10

There isn't really a way for Data Annotations to apply to the elements of a list. What you would have to do is create a wrapper class and apply the Data Annotation to the elements in the wrapper class, like so:

public IList<MyField> MyFields {get;set;}

public class MyField
{
    [RegularExpression("MyRegex", ErrorMessageResourceName = "MyErrorMessage")]
    public string Value
}

Usage:

@Html.TextBoxFor(model => model.MyFields[0].Value)
Mediocre answered 2/5, 2014 at 9:26 Comment(2)
Sounds reasonable, and I'm guessing the validation process would not take Value into account?Plicate
it would treat the string Value as a primitive that can be validated instead of an object which cannot be validated.Mediocre
S
4

You are using DataAnnotations for validations. From what I understand, you are looking for a way to apply the DataAnnotation validation to each element of the list.

Whenever Html.EditorFor is called, it fetches the ModelMetadata of the model that has been passed to it and then fetches any ModelValidators associated with that model. It is the presence of these ModelValidators that result in the 'data-val-*' attributes in the HTML.

When Html.EditorFor is passed a list as a model (or any enumerable for that matter) it first fetches the ModelMetadata and the associated Validators for the property - in your case, it will fetch ModelMetadata associated with the 'MyField' property followed by the validators - 'RegularExpression' in this case. It next iterates through the list of strings and gets the ModelMetadata and Validators for each string. While ModelMetadata has been constructed for each string, there are no Validators that have been specified for these strings. This is the reason that the string is displayed but the validation attributes are not added to the HTML element.

The way I see it, what you are looking for can be achieved by adding the Validator specified on the 'MyField' property to all the list elements at runtime.

This can be done by

  1. Writing a shared editor template for all Collections
  2. Setting the current ModelMetadataProvider to DataAnnotationsModelMetadataProvider
  3. Overriding the 'GetValidators' methd of DataAnnotationsModelValidatorProvider'

The shared editor template for step1 is given below

@model System.Collections.Generic.IEnumerable<object>
@{
    ViewBag.Title = "Collection";
    var modelMetadata = this.ViewData.ModelMetadata;
    var validators = modelMetadata.GetValidators(ViewContext).ToList();
    ViewContext.HttpContext.Items["rootValidators"] = validators;
}

@foreach (var item in Model)
{
    @Html.EditorFor(m => item)
}

You can see in the code above that we are getting all the validators that have been specified on the list. These validators will be added to the elements of the list later. They have been stored in the HttpContext.Items for use in our custom ModelValidatorProvider.

Step 2 - In Global.asax, put in the following code -

ModelValidatorProviders.Providers.Clear();
ModelValidatorProviders.Providers.Add(new DAModelValidatorProvider());

ModelMetadataProviders.Current = new CachedDataAnnotationsModelMetadataProvider();

Step 3 - Write your own ModelValidatorProvider by overriding the GetValidators method as shown in the code below

public class DAModelValidatorProvider : DataAnnotationsModelValidatorProvider
{
    protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {
        var validators = base.GetValidators(metadata, context, attributes).ToList();

        // get root validators of the collection. this was stored in the editor template - fetching it for use now.
        // fetching the rootvalidators inside this method is a bad idea because we have to call GetValidators method on the 
        // containers ModelMetadata and it will result in a non-terminal recursion
        var rootValidators = context.HttpContext.Items["rootValidators"] as IEnumerable<ModelValidator>;
        if (rootValidators != null)
        {
            foreach (var rootValidator in rootValidators)
            {
                validators.Add(rootValidator);
            }
        }

        return validators;
    }
}

Performing the above 3 steps did work for me. However, I've used Html.EditorFor instead of Html.TextBoxFor. Using Html.EditorFor, the way I have has not given me proper id and name attributes - I reckon this to be a trivial issue in the scheme of things. I've created a solution for this and uploaded it on https://github.com/swazza85/Stackoverflow so you can give it a go and see if it fits your needs. What I've done here is not a complete solution by any means but hopefully it gets you going without having to change your models.

Cheers, Swarup.

Siegel answered 2/5, 2014 at 21:42 Comment(6)
Hello, interesting ideas you have here. Instead of solving the problem with a simple solution like the other answer though, you try going deeper into the .NET framework, with a downloadable example, and I find that honorable (thank you so much!). I'm not sure I understand why it doesn't work with TextBoxFor and why the id and name attributes aren't working. Can you perhaps shed some light on the matter?Plicate
Because I have used editor templates, I have used EditorFor instead of TextBoxFor. AFAIK, TextBoxFor uses a tag builder to build an input element of type=text whereas EditorFor looks at the available editor templates and creates the HTML element accordingly - hence the use of EditorFor instead of TextBoxFor.Siegel
The id and name attributes require specifying a postfix string to the Html.Editor method. In step 1 of the answer, if you replace the foreach loop with the foreach loop given below, you should get the name and id fields rendered properly. @{ var i = 0; foreach (var item in Model) { @Html.Editor(string.Format("[{0}]", i)); i++; } } Hope this helps.Siegel
great solution, this helped me out! I believe this is an area MVC could improve.Struma
glad I could be of help :)Siegel
@swazza85, is it safe to clear all other ValidationProviders for just this one custom class?Struma
S
2

I used @swazza85 answer, but had to modify it for my situation. Hopefully if someone else uses his solution they can benefit from my modification. I had to change IEnumerable<object> to IList<object> (or in my case IList<decimal?> because IList<object> throws an error.). Then i had to use the for iterator because the word item was being added to the name attribute and the model binder did not bind those items to my model.

@model System.Collections.Generic.IList<decimal?>

@{
    ViewBag.Title = "Collection";
    var modelMetadata = this.ViewData.ModelMetadata;
    var validators = modelMetadata.GetValidators(ViewContext).ToList();
    ViewContext.HttpContext.Items["rootValidators"] = validators;
}

@for (var i = 0; i < Model.Count(); i++)
{
    @Html.EditorFor(model => Model[i], new { htmlAttributes = new { @class = "form-control" } })
    @Html.ValidationMessageFor(model => Model[i], "", new { @class = "text-danger" })
}

Also if you do not want to clear your providers in the Global.asax file, just return the validators in the if statement and return an empty list outside of it, just note that this editor template must be last in your views or it will run into problems with other properties or templates. You could set ViewContext.HttpContext.Items["rootValidators"] = null at the end of the template.

  protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {
      var validators = base.GetValidators(metadata, context, attributes).ToList();

      // get root validators of the collection. this was stored in the editor template - fetching it for use now.
      // fetching the rootvalidators inside this method is a bad idea because we have to call GetValidators method on the 
      // containers ModelMetadata and it will result in a non-terminal recursion
      var rootValidators = context.HttpContext.Items["rootValidators"] as IEnumerable<ModelValidator>;

      if (rootValidators != null)
      {
        foreach (var rootValidator in rootValidators)
        {
          validators.Add(rootValidator);
        }
        return validators;
      }

      return new List<ModelValidator>();
    }
Struma answered 26/2, 2015 at 13:27 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.