views:

81

answers:

2

Let's say I have

class FooClass { }

class BarClass
{
    public FooClass Foo;
}

This BarClass is the Model I'm passing to the ViewPage.

I'm also passing (via ViewData) an IEnumerable<SelectListItem> with all the Foo in it, and the one that match the bar.Foo is selected (checked at runtime).

I'm then calling Html.DropDownList("Foo", foos);

The dropdownlist renders well, but it does not select the proper item because the html control has the name of the property and it messes with the ViewData.Eval() that runs internaly. It seems to be an accepted behavior (saw many answers about that on SO), so I'm not arguing about that and change the call to the extension to :

Html.DropDownList("DDL_Foo", foos);

The proper value is selected and I'm happy. So I post the form back.

Sadly, in the appropriate Action of my controller, the Foo member is null. So I add a FooModelBinder that implements IModelBinder to intercept the DDL_Foo of the form and initialize the FooClass properly.

But FooModelBinder.BindModel NEVER gets fired and bar.Foo is null. If I change again my view and rename the dropdownlist back to Foo, the FooModelBinder fires as expected and bar.Foo is initialized as it should.

So, what did I missed ? And more important, how am I supposed to do that the proper way. I figured tons of hacks and workaround for it, but that's not what I'm looking for. I want to know how to do it right.

Thank you!

[EDIT] Thank you for your feedback, but I don't think the Prefix is the issue.

About the Binder, I added it because it can't be initialized properly otherwise. Please note that the real case I'm working on is way more complex that what is presented here. This solution is just the smallest mockup I could do to reproduce the issue.

Here it the the revelant code asked (or download the full solution) :

CONTROLLER

    [HttpGet]
    public ActionResult Index()
    {
        var dp = new DummyProvider();
        var bar = dp.GetBar();
        var foos = new List<SelectListItem>();

        dp.GetAllFoos().ForEach(
            f => foos.Add(new SelectListItem {Text = f.Name, Value = f.Id.ToString(), Selected = f.Id == bar.Foo.Id }));

        ViewData["foos"] = foos;

        return View(bar);
    }

    [HttpPost]
    public ActionResult Index(BarClass bar)
    {
        var dp = new DummyProvider();
        var foos = new List<SelectListItem>();

        dp.GetAllFoos().ForEach(
            f => foos.Add(new SelectListItem { Text = f.Name, Value = f.Id.ToString(), Selected = f.Id == bar.Foo.Id }));

        ViewData["foos"] = foos;
        ViewData["selectedItem"] = bar.Foo.Name;

        return View(bar);
    }

VIEW

<%
    var foos = ViewData["foos"] as List<SelectListItem>;

    using(Html.BeginForm())
    {
        %>
        <p>
            <h3>Enter Another Value</h3>
            <%= Html.TextBox("AnotherValue", Model.AnotherValue) %>
        </p>
        <p>
            <h3>Enter Yet Another Value</h3>
            <%= Html.TextBox("YetAnotherValue", Model.YetAnotherValue) %>
        </p>

        <p>
            <h3>Choose a foo</h3>
            <%= Html.DropDownList("DDL_Foo", foos)%>
        </p>
        <button type="submit">Send back !</button>
        <%
    } 
%>

MODEL

public class BarClass
{
    public FooClass Foo { get; set; }
    public string AnotherValue { get; set; }
    public string YetAnotherValue { get; set; }
}

public class FooClass
{
    public Guid Id { get; set; }
    public string Name { get; set; }

}

public class FooClassCollection : List<FooClass> { }

public class FooModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var foo = new FooClass();

        var guid = Guid.Empty;
        if (Guid.TryParse(controllerContext.HttpContext.Request.Form["DDL_Foo"], out guid))
        {
            foo.Id = guid;    
        }


        return foo;
    }
}

public class DummyProvider
{
    public FooClassCollection GetAllFoos()
    {
        return new FooClassCollection
                       {
                           new FooClass {Name = "Item 1", Id = new Guid("4a402abd-ab85-4065-94d6-d9fcc0f9b69e")},
                           new FooClass {Name = "Item 2", Id = new Guid("cf20bfd6-0918-4ffc-a6ec-c4cc4ed30e7f")},
                           new FooClass {Name = "Item 3", Id = new Guid("ad81b882-b93e-42b9-a42c-78376dd8f59d")},
                           new FooClass {Name = "Item 4", Id = new Guid("1511c15d-9ae4-4b18-9e10-e02588c21b27")},
                           new FooClass {Name = "Item 5", Id = new Guid("855e4a2f-fc5b-4117-a888-1dc3ebb990fc")},
                       };
    }

    public BarClass GetBar()
    {
        return new BarClass
                   {
                       AnotherValue = "Nice value",
                       YetAnotherValue = "This one is awesome",
                       Foo = new FooClass {Name = "Item 3", Id = new Guid("ad81b882-b93e-42b9-a42c-78376dd8f59d")}
                   };
    }
}

GLOBAL.ASAX

    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas();
        RegisterRoutes(RouteTable.Routes);

        ModelBinders.Binders.Add(typeof(FooClass), new FooModelBinder());
    }

[EDIT] There is an open issue open on codeplex, if you want it resolved, go vote for it please (even if it had been open for almost a year now).

A: 

Just spent like half an hour playing around with this. I wouldn't go as far to bother with writing custom model binder. I would just use a view model not with the whole FooClass, but with Guid FooId instead. You won't get more out of drop down list anyway. Then this will work:

<%: Html.DropDownListFor(m => m.FooId, foos) %>

When you post back it will correctly bind FooId property.

If BarClass is domain model class, view model could look like this (obv):

public class BarViewModel
{
    public Guid FooId { get; set; }
    public string AnotherValue { get; set; }
    public string YetAnotherValue { get; set; }
}
Necros
Thank you for you suggestion, but that's not the solution I'm looking for. It's actually the solution we used so far, but I wanted to get rid of the specific model that is useless otherwise. For exemple, if I write my own DropDownList, it works perfectly. The issue is in "SelectInternal" in System.Web.Mvc.Html.SelectExtensions, where it get htmlHelper.ViewData.Eval(name); for reasons I don't get, and then reinitialize the Select value of items in the selectitem list, for a reason I don't get either. I think it's to add more "magic" to it. I upgraded the solution with a custom DDL that works.
Bishop
I agree it does behave very weirdly. I was also confused by it.
Necros
A: 

I managed to get everything working by making a BarClassModelBinder that does all the job. Here is the code :

public class BarModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        var bar = new BarClass();

        // In real code, check for nulls, etc.

        bar.AnotherValue = controllerContext.HttpContext.Request.Form["AnotherValue"];
        bar.YetAnotherValue = controllerContext.HttpContext.Request.Form["YetAnotherValue"];

        var guid = Guid.Empty;
        if (Guid.TryParse(controllerContext.HttpContext.Request.Form["DDL_Foo"], out guid))
        {
            bar.Foo = new FooClass {Id = guid};
        }


        return bar;
    }
}

So the only benefit I'm seeing there agains using FormCollection in the Controller is clarity of code. The only thing I'm not comfortable with is that the field name is "hidden" in the ModelBinder, so if someone changes the view, he has to be really careful on the field name. Maybe there is some way to circumvein that issue too, maybe with an attribute. But even without that, this is the lesser evil, so I'll settle with it.

The whole issue still look like a undesirable side effect of the DropDownListFor implementation.

Bishop