1

I'm trying to use Data Annotations to add validation to a List in my model that cannot be empty. I've tried several implementations of a custom attribute, including ones here and here.

My view:

<div class="form-group">
    @* Model has a list of ints, LocationIDs *@
    @Html.LabelFor(model => model.LocationIDs, htmlAttributes: new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        <select class="select2 form-control" multiple id="LocationIDs" name="LocationIDs">
            @* Adds every possible option to select box *@
            @foreach (LocationModel loc in db.Locations)
            {
                <option value="@loc.ID">@loc.Name</option>
            }
        </select>
        @Html.ValidationMessageFor(model => model.LocationIDs, "", new { @class = "text-danger" })
    </div>
</div>

Model:

public class ClientModel
{
    public int ID { get; set; }
    [Required] // Does nothing
    public List<int> LocationIDs { get; set; }
}

Controller:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create([Bind(Include = "ID,LocationIDs")] ClientModel clientModel)
{
    if (ModelState.IsValid)
    {
        db.Clients.Add(clientModel);
        db.SaveChanges();
        return RedirectToAction("Index");
    }

    return View(clientModel);
}

One of the (functionally identical) attributes I've tried:

[AttributeUsage(AttributeTargets.Property)]
public sealed class CannotBeEmptyAttribute : RequiredAttribute
{
    public override bool IsValid(object value)
    {
        var list = value as IEnumerable;
        return list != null && list.GetEnumerator().MoveNext();
    }
}

Currently, checking for a null or empty list passes validation, even if nothing is chosen. In this case, a list of length one, containing the first option, is bound.


I've confirmed that the controller is actually sent a List of length one. I'm not sure how to change this behavior, however. I still think this may be what was described in the below block quote.


I think my problem may be described in this answer's edit, but I'm not sure how to solve it.

Excerpt below:

You'll also have to be careful how you bind your list in your view. For example, if you bind a List to a view like this:

<input name="ListName[0]" type="text" />
<input name="ListName[1]" type="text" />
<input name="ListName[2]" type="text" />
<input name="ListName[3]" type="text" />
<input name="ListName[4]" type="text" /> 

The MVC model binder will always put 5 elements in your list, all String.Empty. If this is how your View works, your attribute would need to get a bit more complex, such as using Reflection to pull the generic type parameter and comparing each list element with default(T) or something.

Servy
  • 202,030
  • 26
  • 332
  • 449
Sinjai
  • 1,085
  • 1
  • 15
  • 34
  • Are you trying to validate that the end-user has selected an item in `select`, or are you trying to validate that `model.LocationIDs` has entries in it, or both? – mjwills Jul 19 '17 at 21:46
  • Both, I suppose. Client-side would be the `select`, server-side would be `model.LocationIDs`. I don't have a 100% thorough understanding of the automagic validation properties, but I do know that data annotations prevents invalid values from being saved (essential) while also providing an error message to the end user. – Sinjai Jul 19 '17 at 21:49
  • @mjwills How could I edit my question to make that more clear? – Sinjai Jul 19 '17 at 21:49
  • I suspect `ValidationMessageFor` may help you for the former (selected an item) but not the latter (has entries in it). – mjwills Jul 19 '17 at 21:51
  • @mjwills `ValidationMessageFor` is just a placeholder for the error message, which gets displayed if validation fails. – Sinjai Jul 19 '17 at 21:52
  • You have not shown your model or the validation attributes applies to its properties. A you have not even ahown a view that binds a form control to a property (your manual ` –  Jul 19 '17 at 21:56
  • @StephenMuecke I guess I take some things for granted. Does the edit help? – Sinjai Jul 19 '17 at 22:00
  • But I did show my model property and the attributes I've applied. – Sinjai Jul 19 '17 at 22:04
  • You showed the POST endpoint. I suspect he wants to see the GET endpoint. – mjwills Jul 19 '17 at 22:05
  • You have not marked the property `public` so it will not bind to anything. And your view code majes no sense. You need to use `ListBoxFor()` to corectly bind and use clunt side validation. –  Jul 19 '17 at 22:09
  • @Stephen That's simply not true. And you're right, I did forget the public initially. As far as I can tell, it goes off of the `name`/`id` HTML attributes. I have other validation working with a nearly identical selectbox. – Sinjai Jul 20 '17 at 01:48
  • What is not true? And you cannot possibly get client side validation because your not generating any `data-val` attributes used by `jquery.validate.unobtrusive` to add the rules to `jquery.validate`. And of course your code will never give you true 2-way model binding either –  Jul 20 '17 at 01:51
  • @Stephen It's not true that binding doesn't happen. Like I said, those helper methods aren't magic (at least outside of client-side validation), they just set the `name` and `id` to match what you want to bind to. Some `data-val` attributes are generated by `ValidationMessageFor` -- by what you're saying, I presume they're not the ones I need. I think I said client-side when I should not have -- the message is displayed, but only after a trip to the server. So, validated client-side, but the error is correctly displayed when applicable. – Sinjai Jul 20 '17 at 14:27
  • Is it possible that some (all?) custom validation attributes aren't supported by client-side validation? My phone number validation element looks like [this](https://i.imgur.com/JgYDCnX.png), using the built-in regex attribute. But I don't see how those `data` attributes could possibly support every type of validation. – Sinjai Jul 20 '17 at 14:33
  • If that's is what you believe then you have an enormous amount to learn! (1) The `HtmlHelper` methods use `ViewData`, the `ModelState` and finally the property value for binding in that order. (2) `ValidationMessageFor()` does not generate any `data-val` attributes (its just a placeholder) - they are generated by the the `EditorFor()`, `TextboxFor`, `DropDownListFor()` etc methods. (3) If the value `LocationIDs` has an initial value(s) - i.e. your editing existing data, they will not be selected when the page is first rendered. –  Jul 20 '17 at 23:27
  • 1
    And if you want to understand valdation, then I suggest you start with [The Complete Guide To Validation In ASP.NET MVC 3 - Part 2](https://www.devtrends.co.uk/blog/the-complete-guide-to-validation-in-asp.net-mvc-3-part-2) –  Jul 20 '17 at 23:28
  • @Stephen I *do* have an enormous amount to learn! Teaching yourself is a slow process. But there's no way the HtmlHelpers do any magic outside of client-side validation. You can [see for yourself](https://i.imgur.com/zkzVuPJ.png), the `name` and `id` attributes are used by the form to have the server sort it out. And to be fair, I didn't say `ValidationMessageFor` generates [*useful* `data-val` attributes](https://i.imgur.com/Bu7TcXy.png). I'm well aware of (3) as well. – Sinjai Jul 21 '17 at 14:24

4 Answers4

1

You can try contructing the validator with a Type and verifying if any of the itens on the list is different of the default value of your type. Changing the example you mentioned here:

public class CannotBeEmptyAttribute : ValidationAttribute
{
    private const string defaultError = "'{0}' must have at least one element.";

    public Type ListType { get; private set; }

    protected CannotBeEmptyAttribute(Type listType) : base(defaultError)
    {
        this.ListType = listType;
    }

    public override bool IsValid(object value)
    {
        object defaultValue = ListType.IsValueType ? Activator.CreateInstance(ListType) : null;

        IEnumerable list = value as IEnumerable;

        if (list != null)
        {
            foreach (var item in list)
            {
                if(item != defaultValue)
                {
                    return true;
                }
            }
        }
        return false;            
    }

    public override string FormatErrorMessage(string name)
    {
        return String.Format(this.ErrorMessageString, name);
    }
}
Pedro Drewanz
  • 1,242
  • 13
  • 14
1

Your CannotBeEmptyAttribute is fine. I've used the exact same code and it works perfectly. Make sure you change your view model to use it as well (instead of the Required which doesn't do what you want it to in this case.

Your custom attribute won't provide you with client-side validation unless you implement it. This means that the form will get posted even though it's invalid but if (ModelState.IsValid) will catch it. Have you used a debugger to see if is IsValid is false?

"Manually" creating the input is completely fine for server-side validation but client-side won't work because the necessary data- attributes are missing.

Here's a minimal version of a form with manually created multi-select input that works with the CannotBeEmptyAttribute:

@using (Html.BeginForm("TestPost", "Home"))
{
    <select multiple="multiple" name="TestList">
        <option value="1">One</option>
        <option value="2">Two</option>
        <option value="3">Three</option>
    </select>
    @Html.ValidationMessage("TestList")
    @Html.ValidationSummary()

    <input type="submit" value="Save"/>
}
Kim
  • 829
  • 7
  • 12
  • I just don't see any way of creating the selectbox I want without doing it manually -- other than extending `HtmlHelper`, but my explorations through the source code have not helped me create anything more advanced than `Html.SimpleText("words")` where `"words"` is built into a string somewhere and returned. – Sinjai Jul 20 '17 at 14:30
  • No, still no idea why. It *does* work for some seeded values, though, where I've actually set the `List` to `null` myself in the database initializer. `IsValid` is true when I send the form myself. I don't know how to check what value is being sent to the controller, though. – Sinjai Jul 20 '17 at 15:01
  • All I can think is that it's actually sending a list containing the first item (that's what eventually gets saved). But I don't know how to solve that. Choosing one item is intended to be valid. – Sinjai Jul 20 '17 at 15:04
  • I've confirmed that the controller is actually sent a `List` of length one. I'm not sure how to change this behavior, however. I still think this may be what was described in the above block quote. – Sinjai Jul 20 '17 at 15:18
1

Also assigning a MinLength worked for me:

[Required, MinLength(1, ErrorMessage = "Atleast one LocationId must be added")]
public List<int> LocationIDs { get; set; }
Magnus
  • 1,422
  • 11
  • 22
0

The only way I could get a zero-selection to actually bind as null was by using Html.ListBoxFor (which I couldn't figure out initially – it should have been done this way from the start):

@Html.ListBoxFor(model => model.SelectedLocations, Model.AllLocations, new { @class = "select2 form-control" })

I haven't gotten client-side validation to work, but I'll post that as another question.

Sinjai
  • 1,085
  • 1
  • 15
  • 34