MVC3 Non-Sequential Indices and DefaultModelBinder
Asked Answered
M

6

47

Is it true that the default model binder in MVC 3.0 is capable of handling non-sequential indices (for both simple and complex model types)? I've come across posts that suggest it should, however in my tests it appears that it does NOT.

Given post back values:

items[0].Id = 10
items[0].Name = "Some Item"
items[1].Id = 3
items[1].Name = "Some Item"
items[4].Id = 6
items[4].Name = "Some Item"

And a controller method:

public ActionResult(IList<MyItem> items) { ... }

The only values that are loaded are items 0 and 1; item 4 is simply ignored.

I've seen numerous solutions to generate custom indices (Model Binding to a List), however they all appear to targeting previous versions of MVC, and most are a bit 'heavy-handed' IMO.

Am I missing something?

Milker answered 22/12, 2011 at 0:49 Comment(0)
L
81

I have this working, you have to remember to add a common indexing hidden input as explained in your referenced article:

The hidden input with name = Items.Index is the key part

<input type="hidden" name="Items.Index" value="0" />
<input type="text" name="Items[0].Name" value="someValue1" />

<input type="hidden" name="Items.Index" value="1" />
<input type="text" name="Items[1].Name" value="someValue2" />

<input type="hidden" name="Items.Index" value="3" />
<input type="text" name="Items[3].Name" value="someValue3" />

<input type="hidden" name="Items.Index" value="4" />
<input type="text" name="Items[4].Name" value="someValue4" />

hope this helps

Lamanna answered 22/12, 2011 at 1:1 Comment(7)
I was hoping to avoid this approach. I had my fingers crossed that the default model binder would simply resolve the missing indice itself. There must be a reason (perhaps for more complex situations?) for having to explicitly specify the index. Either way, thanks for the quick reply and the sample code.Milker
OH MY NUL! This just made returning lists SO much easier, instead of doing ridiculous for ( i++) I can just use the Primary Key or any other ID on the Index, and the List comes back so nice, and strongly typed. These dark, hidden secrets. This made my day! +1 +beer!!!Bisutun
@Yablargo, that's probably the ModelState feature. Are you using this in the post action?Squally
@Squally I'm not really sure with this comment, I'm going to remove it. I have since been using this functionality all over and can't really speak to the issue I was having at the time.Longicorn
Is there a client-side framework (like Knockout), that would generate and manage these indexers for DefaultModelBinder?Jeuz
Doing this in a PartialView (i.e. each partial is an item from the list) I used the following: <input type="hidden" name="@(ViewData.TemplateInfo.HtmlFieldPrefix.Substring(0, ViewData.TemplateInfo.HtmlFieldPrefix.LastIndexOf("["))).Index" value="@ViewData.TemplateInfo.HtmlFieldPrefix.Split("[]".ToCharArray, StringSplitOptions.RemoveEmptyEntries).Last" />Photoreconnaissance
works when value attribute (value="N") of hidden matches following (name="Items[N].Name")Backhanded
D
6

The article you referenced is an old one (MVC2), but as far as I know, this is still the defacto way to model bind collections using the default modelbinder.

If you want non-sequential indexing, like Bassam says, you will need to specify an indexer. The indexer does not need to be numeric.

We use Steve Sanderson's BeginCollectionItem Html Helper for this. It automatically generates the indexer as a Guid. I think this is a better approach than using numeric indexers when your collection item HTML is non-sequential.

Discussant answered 22/12, 2011 at 3:57 Comment(1)
I came across that same article, and it definitely did address the the issue I was describing. As mentioned above I was hoping that the default model binder would handle this situation internally and that the BeginCollectionItem helper wasn't actually needed. Thanks for the reply!Milker
S
5

This helper method, derived from Steve Sanderson's approach, is much simpler and can be used to anchor any item in a collection and it seems to work with MVC model binding.

public static IHtmlString AnchorIndex(this HtmlHelper html)
{
    var htmlFieldPrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
    var m = Regex.Match(htmlFieldPrefix, @"([\w]+)\[([\w]*)\]");
    if (m.Success && m.Groups.Count == 3)
        return
            MvcHtmlString.Create(
                string.Format(
                    "<input type=\"hidden\" name=\"{0}.index\" autocomplete=\"off\" value=\"{1}\" />",
                    m.Groups[1].Value, m.Groups[2].Value));
    return null;
}

E.g. Simply call it in an EditorTemplate, or anywhere else you would generate inputs, as follows to generate the index anchoring hidden variable if one is applicable.

@model SomeViewModel
@Html.AnchorIndex()
@Html.TextBoxFor(m => m.Name)
... etc.

I think it has a few advantages over Steve Sanderson's approach.

  1. It works with EditorFor and other inbuilt mechanisms for processing enumerables. So if Items is an IEnumerable<T> property on a view model, the following works as expected:

    <ul id="editorRows" class="list-unstyled"> @Html.EditorFor(m => m.Items) @* Each item will correctly anchor allowing for dynamic add/deletion via Javascript *@ </ul>

  2. It is simpler and doesn't require any more magic strings.

  3. You can have a single EditorTemplate/DisplayTemplate for a data type and it will simply no-op if not used on an item in a list.

The only downside is that if the root model being bound is the enumerable (i.e. the parameter to the Action method itself and not simply a property somewhere deeper in the parameter object graph), the binding will fail at the first non-sequential index. Unfortunately, the .Index functionality of the DefaultModelBinder only works for non-root objects. In this scenario, your only option remains to use the approaches above.

Silver answered 24/1, 2014 at 4:11 Comment(1)
Many thanks for this Phil. I'm heavily committed to using EditorFor with enumerables and this worked like a charm!Squatter
M
2

I was struggling with this this week and Bassam's answer was the key to getting me on the right track. I have a dynamic list of inventory items that can have a quantity field. I needed to know how many of which items they selected, except the list of items can vary from 1 to n.

My solution was rather simple in the end. I created a ViewModel called ItemVM with two properties. ItemID and Quantity. In the post action I accept a list of these. With Indexing on, all items get passed.. even with a null quantity. You have to validate and handle it server side, but with iteration it's trivial to handle this dynamic list.

In my View I am using something like this:

@foreach (Item item in Items)
{
<input type="hidden" name="OrderItems.Index" value="@item.ItemID" />
<input type="hidden" name="OrderItems[@item.ItemID].ItemID" value="@item.ItemID" />
<input type="number" name="OrderItems[@item.ItemID].Quantity" />
}

This gives me a List with a 0-based Index, but iteration in the controller extracts all the necessary data from a new strongly-typed model.

public ActionResult Marketing(List<ItemVM> OrderItems)
...
        foreach (ItemVM itemVM in OrderItems)
            {
                OrderItem item = new OrderItem();
                item.ItemID = Convert.ToInt16(itemVM.ItemID);
                item.Quantity = Convert.ToInt16(itemVM.Quantity);
                if (item.Quantity > 0)
                {
                    order.Items.Add(item);
                }
            }

You will then end up with a collection of Items that have a quantity greater than 0, and the Item ID.

This technique is working in MVC 5 utilizing EF 6 in Visual Studio 2015. Maybe this will help someone searching for this solution like I was.

Mental answered 10/10, 2015 at 15:4 Comment(1)
Could you provide the full example? I have very similar situationThird
W
1

Or use this javascript function to fix the indexing: (Replace EntityName and FieldName obviously)

function fixIndexing() {
        var tableRows = $('#tblMyEntities tbody tr');

        for (x = 0; x < tableRows.length; x++) {
            tableRows.eq(x).attr('data-index', x);

            tableRows.eq(x).children('td:nth-child(1)').children('input:first').attr('name', 'EntityName[' + x + "].FieldName1");

            tableRows.eq(x).children('td:nth-child(2)').children('input:first').attr('name', 'EntityName[' + x + "].FieldName2");

            tableRows.eq(x).children('td:nth-child(3)').children('input:first').attr('name', 'EntityName[' + x + "].FieldName3");
        }

        return true; //- Submit Form -
    }
Whoa answered 14/1, 2015 at 8:55 Comment(0)
D
1

I ended up making a more generic HTML Helper:-

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.Mvc;

namespace Wallboards.Web.Helpers
{
    /// <summary>
    /// Hidden Index Html Helper
    /// </summary>
    public static class HiddenIndexHtmlHelper
    {
        /// <summary>
        /// Hiddens the index for.
        /// </summary>
        /// <typeparam name="TModel">The type of the model.</typeparam>
        /// <typeparam name="TProperty">The type of the property.</typeparam>
        /// <param name="htmlHelper">The HTML helper.</param>
        /// <param name="expression">The expression.</param>
        /// <param name="index">The Index</param>
        /// <returns>Returns Hidden Index For</returns>
        public static MvcHtmlString HiddenIndexFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, int index)
        {
            var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
            var propName = metadata.PropertyName;

            StringBuilder sb = new StringBuilder();
            sb.AppendFormat("<input type=\"hidden\" name=\"{0}.Index\" autocomplete=\"off\" value=\"{1}\" />", propName, index);

            return MvcHtmlString.Create(sb.ToString());
        }
    }
}

And then include it in each iteration of the list element in your Razor view:-

@Html.HiddenIndexFor(m => m.ExistingWallboardMessages, i)
Diaphoretic answered 10/3, 2017 at 9:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.