Search code examples
c#asp.net-mvcmodel-bindingcustom-model-binder

MVC Model Binding Dynamic List of Lists


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.


Solution

  • 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.