Question

I have a navigation property (Category) on a Question class for which I am manually creating a DropDownList for in the Create view of Question, and when posting the Create action, the Category navigation property is not populated on the Model, therefore giving me an invalid ModelState.

Here is my model:

 public class Category
    {
        [Key]
        [Required]
        public int CategoryId { get; set; }

        [Required]
        public string CategoryName { get; set; }

        public virtual List<Question> Questions { get; set; }
    }

public class Question
    {
        [Required]
        public int QuestionId { get; set; }

        [Required]
        public string QuestionText { get; set; }

        [Required]
        public string Answer { get; set; }

        [ForeignKey("CategoryId")]
        public virtual Category Category { get; set; }

        public int CategoryId { get; set; }
    }

Here is my Question controller for both GET and POST actions of Create:

public ActionResult Create(int? id)
        {
            ViewBag.Categories = Categories.Select(option => new SelectListItem {
                Text = option.CategoryName,
                Value = option.CategoryId.ToString(),
                Selected = (id == option.CategoryId)
            });
            return View();
        }


        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult Create(Question question)
        {
            if (ModelState.IsValid)
            {
                db.Questions.Add(question);
                db.SaveChanges();
                return RedirectToAction("Index");
            }

            return View(question);
        }

And here is the Create view for Question

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(true)

    <fieldset>
        <legend>Question</legend>

        <div class="editor-label">
            @Html.LabelFor(model => model.Category)
        </div>
        <div class="editor-field">
            @Html.DropDownListFor(model => model.Category.CategoryId, (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")
        </div>
        <div class="editor-label">
            @Html.LabelFor(model => model.QuestionText)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.QuestionText)
            @Html.ValidationMessageFor(model => model.QuestionText)
        </div>

        <div class="editor-label">
            @Html.LabelFor(model => model.Answer)
        </div>
        <div class="editor-field">
            @Html.EditorFor(model => model.Answer)
            @Html.ValidationMessageFor(model => model.Answer)
        </div>

        <p>
            <input type="submit" value="Create" />
        </p>
    </fieldset>
}

I have tried the following variations of generating the dropdownlist on the view:

@Html.DropDownListFor(model => model.Category.CategoryId, (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")
@Html.DropDownListFor(model => model.Category, (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")
@Html.DropDownList("Category", (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")
@Html.DropDownList("CategoryId", (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")

When I quickwatch the Question object on the POST action, the Category property is null, but the CategoryId field on the property is set to the selected Category on the view.

I know I could easily add code to manually fetch the Category with EF by using the CategoryId value that I get from the view. I also think I could create a custom binder to do this, but I was hoping that this could be done with data annotations.

Am I missing something?

Is there a better way to generate the dropdownlist for the navigation property?

Is there a way to let MVC know how to populate the navigation property without me having to manually do it?

-- EDIT:

If it makes any difference, I do not need the actual navigation property loaded when creating/saving the Question, I just need the CategoryId to be correctly saved to the Database, which isn't happening.

Thanks

Was it helpful?

Solution

Instead of

        @Html.DropDownListFor(model => model.Category.CategoryId, (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")

Try

        @Html.DropDownListFor(model => model.CategoryId, (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")

Edit:

There is no automatic way to populate Navigation property from the id posted from the form. Because, a database query should be issued to get the data and it should not be transparent. It should be done explicitly. Moreover, doing this operation in a custom binder probably probably is not the best way. There is a good explanation in this link : Inject a dependency into a custom model binder and using InRequestScope using Ninject

OTHER TIPS

I know this question is already answered, but it got me thinking.

So I think I found a way of doing this with some conventions.

First, I made the entities inherit from a base class like this:

public abstract class Entity
{
}
public class Question : Entity
{
    [Required]
    public int QuestionId { get; set; }

    [Required]
    public string QuestionText { get; set; }

    [Required]
    public string Answer { get; set; }

    public virtual Category Category { get; set; }
}
public class Category : Entity
{
    [Key]
    [Required]
    public int CategoryId { get; set; }

    [Required]
    public string CategoryName { get; set; }

    public virtual List<Question> Questions { get; set; }
}

So, I also changed the Question model to not have an extra property called CategoryId.

For the form all I did was:

@Html.DropDownList("CategoryId", (IEnumerable<SelectListItem>)ViewBag.Categories, "Select a Category")

So here's the second convention, you'd have to have a property field be named with an Id suffix.

Finally, the CustomModelBinder and CustomModelBinderProvider

public class CustomModelBinderProvider : IModelBinderProvider
    {
        private readonly IKernel _kernel;

        public CustomModelBinderProvider(IKernel kernel)
        {
            _kernel = kernel;
        }

        public IModelBinder GetBinder(Type modelType)
        {
            if (!typeof(Entity).IsAssignableFrom(modelType))
                return null;

            Type modelBinderType = typeof (CustomModelBinder<>)
                .MakeGenericType(modelType);

            // I registered the CustomModelBinder using Windsor
            return _kernel.Resolve(modelBinderType) as IModelBinder;
        }
    }

public class CustomModelBinder : DefaultModelBinder where T : Entity { private readonly QuestionsContext _db;

public CustomModelBinder(QuestionsContext db)
{    
    _db = db;
}

public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
    var model = base.BindModel(controllerContext, bindingContext) as T;

    foreach (var property in typeof(T).GetProperties())
    {
        if (property.PropertyType.BaseType == typeof(Entity))
        {
            var result = bindingContext.ValueProvider.GetValue(string.Format("{0}Id", property.Name));
            if(result != null)
            {
                var rawIdValue = result.AttemptedValue;
                int id;
                if (int.TryParse(rawIdValue, out id))
                {
                    if (id != 0)
                    {
                        var value = _db.Set(property.PropertyType).Find(id);
                        property.SetValue(model, value, null);
                    }
                }
            }
        }
    }
    return model;
}

}

The CustomModelBinder will look for properties of type Entity and load the data with the passed Id using EF.

Here I am using Windsor to inject the dependencies, but you could use any other IoC container.

And that's it. You have a way to make that binding automagically.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top