3

I have a dynamic list of dynamic lists, which have <input />s that need to be POSTed to an MVC controller/action and bound as a typed object. The crux of my problem is I can't figure out how to manually pick out arbitrary POSTed form values in my custom model binder. Details are below.

I have a list of US States that each have a list of Cities. Both States and Cities can be dynamically added, deleted, and re-ordered. So something like:

public class ConfigureStatesModel
{
    public List<State> States { get; set; }
}

public class State
{
    public string Name { get; set; }
    public List<City> Cities { get; set; }
}

public class City
{
    public string Name { get; set; }
    public int Population { get; set; }
}

The GET:

public ActionResult Index()
{
    var csm = new ConfigureStatesModel(); //... populate model ...
    return View("~/Views/ConfigureStates.cshtml", csm);
}

The ConfigureStates.cshtml:

@model Models.ConfigureStatesModel
@foreach (var state in Model.States)
{
    <input name="stateName" type="text" value="@state.Name" />
    foreach (var city in state.Cities)
    {
        <input name="cityName" type="text" value="@city.Name" />
        <input name="cityPopulation" type="text" value="@city.Population" />
    }
}

(There is more markup and javascript, but I leave it out for brevity/simplicity.)

All form inputs are then POSTed to server, as so (parsed by Chrome Dev Tools):

stateName: California
cityName: Sacramento
cityPopulation: 1000000
cityName: San Francisco
cityPopulation: 2000000
stateName: Florida
cityName: Miami
cityPopulation: 3000000
cityName: Orlando
cityPopulation: 4000000

I need to capture the form values, ideally bound as a List<State> (or, equivalently, as a ConfigureStatesModel), as so:

[HttpPost]
public ActionResult Save(List<State> states)
{
    //do some stuff
}

A custom model binder seems like the right tool for the job. But I don't know how to know which city names and city populations belong to which state names. That is, I can see all the form keys and values POSTed, but I don't see a way to know their relation:

public class StatesBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        //California, Florida
        List<string> stateNames = controllerContext.HttpContext.Request.Form.GetValues("stateName").ToList();

        //Sacramento, San Francisco, Miami, Orlando
        List<string> cityNames = controllerContext.HttpContext.Request.Form.GetValues("cityName").ToList();

        //1000000, 2000000, 3000000, 4000000
        List<int> cityPopulations = controllerContext.HttpContext.Request.Form.GetValues("cityPopulation")
            .Select(p => int.Parse(p)).ToList();

        // ... build List<State> ...
    }
}

If I could just know the order all values came in in relation to all other form values, that would be enough. The only way I see to do this is looking at the raw request stream, as so:

Request.InputStream.Seek(0, SeekOrigin.Begin);
string urlEncodedFormData = new StreamReader(Request.InputStream).ReadToEnd();

but I don't want to be messing with manually parsing that.

Also note that the order of the list of states and the order of the lists of cities in each state matter, as I persist the concept of display-order for them. So that would need to be preserved from the form values as well.

I've tried variations of dynamic list binding like this and this. But it feels wrong junking up the html and adding a lot of (error-prone) javascript, just to get the binding to work. The form values are already there; it should just be a matter of capturing them on the server.

BrianS
  • 51
  • 2
  • 6
  • Can you share the class that you want to pass to MVC action ? As per my understanding you want to pass multiple states , for that use `for` instead of `foreach`. – J Santosh Mar 30 '18 at 03:49
  • Get rid of your custom ModelBinder which will never work. You need to generate the view correctly. Refer [this answer](http://stackoverflow.com/questions/28019793/submit-same-partial-view-called-multiple-times-data-to-controller/28081308#28081308) for some options –  Mar 30 '18 at 05:07
  • Note for nested collections, you need to use [this plugin](http://www.joe-stevens.com/2011/06/06/editing-and-binding-nested-lists-with-asp-net-mvc-2/) rather than `BeginCollectionItem` –  Mar 30 '18 at 05:17
  • Or if you want to do it all client side - refer [this DotNetFiddle](https://dotnetfiddle.net/I8q07y) –  Mar 30 '18 at 05:19

2 Answers2

0

The only obvious way I see of building a form that will actually represent which cities belong to which state would require that you use the strongly-typed helpers.

So, I'd use something similar to:

@model Models.ConfigureStatesModel

@for (int outer = 0; outer < Model.States.Count; outer++)
{
    <div class="states">
        @Html.TextBoxFor(m => m.States[outer].Name, new { @class="state" })
        for (int inner = 0; inner < Model.States[outer].Cities.Count; inner++)
        {
            <div class="cities">
                @Html.TextBoxFor(m => m.States[outer].Cities[inner].Name)
                @Html.TextBoxFor(m => m.States[outer].Cities[inner].Population)
            </div>
        }
    </div>
}

This will create inputs with form names that the default modelbinder can handle.

The part that requires some additional work is handling the re-ordering. I would use something like this, assuming you are using jQuery already:

// Iterate through each state
$('.states').each(function (i, el) {
    var state = $(this);
    var input = state.find('input.state');

    var nameState = input.attr('name');
    if (nameState != null) {
        input.attr('name', nameState.replace(new RegExp("States\\[.*\\]", 'gi'), '[' + i + ']'));
    }

    var idState = input.attr('id');
    if (idState != null) {
        input.attr('id', idState.replace(new RegExp("States_\\d+"), i));
    }

    // Iterate through the cities associated with each state
    state.find('.cities').each(function (index, elem) {
        var inputs = $(this).find('input');

        inputs.each(function(){
            var cityInput = (this);

            var nameCity = cityInput.attr('name');
            if (nameCity != null) {
                cityInput.attr('name', nameCity.replace(new RegExp("Cities\\[.*\\]", 'gi'), '[' + index + ']'));
            }

            var idCity = cityInput.attr('id');
            if (idCity != null) {
                cityInput.attr('id', idCity.replace(new RegExp("Cities_\\d+"), index));
            }
        });
    });
});

This last bit probably requires some tweaking, as it's untested, but it's similar to something I've done before. You would call this whenever the items on your view are added/edited/removed/moved.

Tieson T.
  • 20,774
  • 6
  • 77
  • 92
  • All you need is to add a hidden input for the collection indexer which allows non-zero, non-consecutive collection items to be bound - e.g. `` for the outer collection –  Mar 30 '18 at 05:05
0

I came up with my own solution. It's a little bit of a hack, but I feel it's better than the alternatives. The other solution and suggestions all involved altering the markup and adding javascript to synchronize the added markup -- which I specifically said I did not want to do in the OP. I feel adding indexes to the <input /> names is redundant if said <input />s are already ordered in the DOM the way you want them. And adding javascript is just one more thing to maintain, and unnecessary bits sent through the wire.

Anyways .. My solution involves looping through the raw request body. I hadn't realized before that this is basically just a url-encoded querystring, and it's easy to work with after a simple url-decode:

public class StatesBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        controllerContext.HttpContext.Request.InputStream.Seek(0, SeekOrigin.Begin);
        string urlEncodedFormData = new StreamReader(controllerContext.HttpContext.Request.InputStream).ReadToEnd();
        var decodedFormKeyValuePairs = urlEncodedFormData
            .Split('&')
            .Select(s => s.Split('='))
            .Where(kv => kv.Length == 2 && !string.IsNullOrEmpty(kv[0]) && !string.IsNullOrEmpty(kv[1]))
            .Select(kv => new { key = HttpUtility.UrlDecode(kv[0]), value = HttpUtility.UrlDecode(kv[1]) });
        var states = new List<State>();
        foreach (var kv in decodedFormKeyValuePairs)
        {
            if (kv.key == "stateName")
            {
                states.Add(new State { Name = kv.value, Cities = new List<City>() });
            }
            else if (kv.key == "cityName")
            {
                states.Last().Cities.Add(new City { Name = kv.value });
            }
            else if (kv.key == "cityPopulation")
            {
                states.Last().Cities.Last().Population = int.Parse(kv.value);
            }
            else
            {
                //key-value form field that can be ignored
            }
        }
        return states;
    }
}

This assumes that (1) the html elements are ordered on the DOM correctly, (2) are set in the POST request body in the same order, and (3) are received in the request stream on the server in the same order. To my understanding, and in my case, these are valid assumptions.

Again, this feels like a hack, and doesn't seem very MVC-y. But it works for me. If this happens to help someone else out there, cool.

BrianS
  • 51
  • 2
  • 6