Tuesday, May 15, 2012

Learning MVC: Updating the Many-to-many Relationship with MultiSelectList

Continuing the application from the last post, I was now going to use a MultiSelectList to update the many-to-many relationship. The use case is the following: suppose we have a recipe but want to update it - maybe the dish will benefit from adding a bit of pepper. So how do I go about adding something to the list of ingredients? The solution was not so straightforward and there were at least three "gotchas" on the way.

Gotcha 1. To actually populate the MultiSelectList. The easy (but not quite straightforward) task was to create the MultiSelectList and to populate it with all the ingredients. To do that I added the properties to the Recipe class called AllIngredients and SelectedIngredientIDs. The most basic Recipe class now looks like this:

public class Recipe
{
 [ScaffoldColumn(false)]
 public int RecipeID { get; set; }
 public string RecipeName { get; set; }

 public virtual ICollection<Ingredient> Ingredients { get; set; }
 public IEnumerable<int> SelectedIngredientIDs { get; set; }
 public ICollection<Ingredient> AllIngredients { get; set; }

 public Recipe()
 {
  Ingredients = new HashSet<Ingredient>();
 }
}

In the controller, I populate all ingredients from the database into the AllIngredients and then the IDs of the ingredients of the recipe into the SelectedIngredientIDs.

Recipe recipe = recipeDB.Recipes.Find(id);
recipe.AllIngredients = recipeDB.Ingredients.ToList();

recipe.SelectedIngredientIDs = Enumerable.Empty<int>();
foreach (Ingredient ing in recipe.Ingredients)
{
 recipe.SelectedIngredientIDs = recipe.SelectedIngredientIDs.Concat(new[] {ing.IngredientID});
}

Then I create a MultiSelectList as follows

@Html.ListBoxFor(model => model.SelectedIngredientIDs, new MultiSelectList(Model.AllIngredients, "IngredientID", "IngredientName"), new {Multiple = "multiple"})

Gotcha 2. Preselect the current ingredients in the list. The application works by now and the list is populated, but nothing is selected. Why is that? I checked the SelectedIngredientIDs and they are populated properly. The trick was to find out that MVC uses the ToString method as a way to determine if an item is selected or not, so I had to override it in the Ingredient class. Just added a piece of code below and it started working like magic.

public class Ingredient
{
 public int IngredientID { get; set; }
 
 ...
 
 public override string ToString()
 {
  return this.IngredientID.ToString();
 }
}

Gotcha 3. Finally, I had my list being populated and I also could change the selection to my content. However, no exceptions were thrown but also no updates were being saved to the database. The not-so-little trick was to find out how exactly to let the Entity Framework know what needs to be updated. Here is the slightly simplified HttpPost method (try/catch omitted etc) which worked, with comments.

[HttpPost]
public ActionResult Edit(Recipe recipe)
{
 if(ModelState.IsValid)
 {
  //get the id of the current recipe
  int id = recipe.RecipeID;
  //load recipe with ingredients from the database
  var recipeItem = recipeDB.Recipes.Include(r => r.Ingredients).Single(r => r.RecipeID == id);
  //apply the values that have changed
  recipeDB.Entry(recipeItem).CurrentValues.SetValues(recipe);
  //clear the ingredients to let the framework know they have to be processed
  recipeItem.Ingredients.Clear();
  //now reload the ingredients again, but from the list of selected ones as per model provided by the view
  foreach (int ingId in recipe.SelectedIngredientIDs)
  {
   recipeItem.Ingredients.Add(recipeDB.Ingredients.Find(ingId));
  }
  //finally, save changes as usual
  recipeDB.SaveChanges();
  return RedirectToAction("Index");
 }
 return View(recipe);
}

I'm anticipating the gotchas in the Create view already!

References I used

ASP.NET MVC MultiSelectList with selected values not selecting properly
MVC 3 Quirk: MultiSelect with selected items
Many-To-Many Relationship Basic Example (MVC3) by . Also posted on my website

No comments: