I'm coming across the same problem in my MVC 3 applications. I've got a view to create an new product and that product can be assigned to one or more categories. Here are my EF Code First Model Classes:
public class Product
{
public int ProductID { get; set; }
public string Name { get; set; }
public virtual ICollection<Category> Categories { get; set; }
}
public class Category
{
public int CategoryID { get; set; }
public string Name { get; set; }
public virtual ICollection<Product> Products { get; set; }
}
So, I create a view model for the create product view and include the product and a list of the categories:
public class ProductEditViewModel
{
public Product Product { get; set; }
public List<SelectListItem> CategorySelections { get; set; }
public ProductEditViewModel(Product product, List<Category> categories)
{
this.Product = product;
CategorySelections = categories.Select(c => new SelectListItem()
{
Text = c.Name,
Value = c.CategoryID.ToString(),
Selected = (product != null ? product.Categories.Contains(c) : false)
}).ToList();
}
}
So, I render a view with an input for the name and a list of checkboxes for each category (named "Product.Categories"). When my form gets posted back I want to save the product with its associated categories (or if the ModelState is invalid, to redisplay the view with the category selections the user made intact).
[HttpPost]
public ActionResult Create(Product product)
{
if (ModelState.IsValid)
{
db.Products.Add(product);
db.SaveChanges();
return RedirectToAction("Index");
}
return View(new ProductEditViewModel(product, db.Categories.ToList()));
}
When i do that and select one or more categories, the ModelState is invalid and it returns the Edit view with the following validation error:
The value '25,2' is invalid. // 25 and 2 being the CategoryIDs
It makes sense to me that it can't bind 25 and 2 into actual category objects, but is there a standard way to use a custom ModelBinder that would allow me to translate IDs into Categories and attach them to the context?
What you could try is the following: Bind to your ViewModel instead of Product
in your post action:
[HttpPost]
public ActionResult Create(ProductEditViewModel viewModel)
{
if (ModelState.IsValid)
{
foreach (var value in viewModel.CategorySelections
.Where(c => c.Selected)
.Select(c => c.Value))
{
// Attach "stub" entity only with key to make EF aware that the
// category already exists in the DB to avoid creating a new category
var category = new Category { CategoryID = int.Parse(value) };
db.Categories.Attach(category);
viewModel.Product.Categories.Add(category);
}
db.Products.Add(viewModel.Product);
db.SaveChanges();
return RedirectToAction("Index");
}
return View(new ProductEditViewModel(
viewModel.Product, db.Categories.ToList()));
}
I am not sure though if this is the "standard way".
Edit
The return
case when the model is invalid cannot work in my example above because viewModel.Product.Categories
collection is empty, so you would get no selected category item in the view and not the items which the user had selected before.
I don't know how exactly you bind the collection to the view (your "list of checkboxes"?) but when using a ListBox
which allows multiple selection then there seems to be a solution along the lines of this answer: Challenges with selecting values in ListBoxFor. I just had asked Darin in the comments if the list of selected item ids also will get bound to the ViewModel in an post action and he confirmed that.
Thanks @Slauma, that got me on the right track. Here is my Create and Edit post methods that detail how to manage the relationships (the edit is a bit trickier, because it has to add items that don't exist in the database and delete items that have been removed and do exist in the database). I added a SelectedCategories property (List of ints) to my ProductEditViewModel to hold the result from the form.
[HttpPost]
public ActionResult Create(ProductEditViewModel)
{
viewModel.Product.Categories = new List<Category>();
foreach (var id in viewModel.SelectedCategories)
{
var category = new Category { CategoryID = id };
db.Category.Attach(category);
viewModel.Product.Categories.Add(category);
}
if (ModelState.IsValid)
{
db.Products.Add(viewModel.Product);
db.SaveChanges();
return RedirectToAction("Index");
}
return View(new ProductEditViewModel(viewModel.Product, GetCategories()));
}
For the Edit method I had to query the database for the current product and then compare that with the viewModel.
[HttpPost]
public ActionResult Edit(ProductEditViewModel viewModel)
{
var product = db.Products.Find(viewModel.Product.ProductID);
if (ModelState.IsValid)
{
UpdateModel<Product>(product, "Product");
var keys = product.CategoryKeys; // Returns CategoryIDs
// Add categories not already in database
foreach (var id in viewModel.SelectedCategorys.Except(keys))
{
var category = new Category { CategoryID = id }; // Create a stub
db.Categorys.Attach(category);
product.Categories.Add(Category);
}
// Delete categories not in viewModel, but in database
foreach (var id in keys.Except(viewModel.SelectedCategories))
{
var category = product.Categories.Where(c => c.CategoryID == id).Single();
product.Categories.Remove(category);
}
db.SaveChanges();
return RedirectToAction("Index");
}
else
{
// Update viewModel categories so it keeps users selections
foreach (var id in viewModel.SelectedCategories)
{
var category = new Category { CategoryID = id }; // Create a stub
db.Categories.Attach(category);
viewModel.Product.Categories.Add(category);
}
}
return View(new ProductEditViewModel(viewModel.Product, GetCategories()));
}
It is more code that I was hoping it would be, but it is actually pretty efficient with using the stubs and only adding/deleting what has changed.
I had a similar issue few days ago. Ended up using a "hack" - MVC 3 - Binding to a Complex Type with a List type property
Please leave a message if you find an alternative way.