Implementing A CheckBoxList-Style Interface in ASP.net MVC3

  • RSS

Statistics

  • Entries (12)
  • Comments (2)

About the Author

Chris Iveson, CodeBox OwnerChris Iveson has been developing software professionally on Microsoft based systems for over 12 years, building web and Windows-based solutions for the maritime and offshore industries. Chris has been based in Thailand since 2001, working with several shipping companies in the South-East Asia region, and enjoying learning the "ins and outs" of being based in Asia. When not attached to an keyboard attached to an LCD screen, Chris can be found either down the gym, or championing the latest top British TV.

Chris's LinkedIn Profile

Posted by Chris Monday, October 24, 2011 2:05:00 PM

Searching around, this is a task which appears to catch a few people out. One method of implementing a list of checkboxes has been summarised quite well over on StackOverflow. The problem I found with this summarisation however is it didn't quite document to update database entities, particularly entities which provide M:M (many-to-many) relationships between two other entities (e.g. Books and Categories, Courses and Students etc).

So first let's get started with the code for those entities, which should make it more clearer what we're trying to achieve:

namespace SomeSite.Domain.Entities
{
  [Table(Name = "Books")]
  public class Book
  {
    [ScaffoldColumn(false)]
    [Column(IsPrimaryKey = true, 
            IsDbGenerated = true, 
            AutoSync = AutoSync.OnInsert)]

    public int BookID { get; set; }
    
    [Display(Name = "Book Name")]
    [Required(ErrorMessage = 
      "Please enter an Book name")]
    [Column] public string BookName { get; set; }

    [Display(Name = "Book Author")]
    [Required(ErrorMessage = 
      "Please enter an Book author")]
    [Column] public string BookName { get; set; }

    private EntitySet<BookCategory> 
      _bookCategorys = new EntitySet<BookCategory>();

    [System.Data.Linq.Mapping.Association(
      Name = "FK_Categories_Books", 
      Storage = "_BookCategories", 
      OtherKey = "BookID", ThisKey = "BookID")]

    public ICollection<BookCategory> 
      BookCategories
    {
      get { return _BookCategories; }
      set { _BookCategories.Assign(value); }
    }
  }

  [Table(Name = "BookCategories")]
  public class BookCategory
  {
    [ScaffoldColumn(false)]
    [Column(IsPrimaryKey = true, 
            IsDbGenerated = true, 
            AutoSync = AutoSync.OnInsert)]
    public int BookCategoryID;

    [Column] public int BookID;

    private EntityRef _book = 
      new EntityRef();

    [System.Data.Linq.Mapping.Association(
       Name = "FK_BookCategories_Books", 
       IsForeignKey = true, 
       Storage = "_book", 
       ThisKey = "BookID", 
       OtherKey = "BookID")]

    public Book Book
    {
      get { return _Book.Entity; }
      set { _Book.Entity = value; }
    }

    [Column] public int CategoryID;
    private EntityRef _category = 
      new EntityRef();

    [System.Data.Linq.Mapping.Association(
      Name = "FK_BookCategories_Categories", 
      IsForeignKey = true, 
      Storage = "_category", 
      ThisKey = "CategoryID", 
      OtherKey = "CategoryID")]

    public Category Category
    {
      get { return _Category.Entity; }
      set { _Category.Entity = value; }
    }
  }

  [Table(Name = "Categories")]
  public class Category
  {
    [ScaffoldColumn(false)]
    [Column(IsPrimaryKey = true, 
            IsDbGenerated = true, 
            AutoSync = AutoSync.OnInsert)]
    public int CategoryID { get; set; }
    
    [Display(Name = "Category Name")]
    [Required( ErrorMessage = 
      "Please enter an Category name")]
    [Column] public string CategoryName { get; set; }

    private EntitySet<BookCategory> 
      _BookCategories = 
      new EntitySet<BookCategory>();

    [System.Data.Linq.Mapping.Association(
       Name = "FK_Categories_Books", 
       Storage = "_BookCategoriess", 
       OtherKey = "CategoryID", 
       ThisKey = "CategoryID")]

    public ICollection<BookCategory> 
      BookCategories
    {
      get { return _BookCategories; }
      set { _BookCategories.Assign(value); }
    }
  }
}

Looks complex, but really three simple entities - two containing data ("Book" and "Category"), with a third "BookCategory" entity providing the many-to-many relationship. The entity classes have been marked up with LinqToSql and data modelling attributes which determine how they are presented to the user and entered into the database.

Problem is if we want to create a MVC3 web form allowing a user to specify the name, author and categories of a book, we can't just throw the Book entity as the model between the controller and the view. Doing so will give us the Name and Author prompts, but nothing else. What's needed is specific model which references the Book object we want to modify, along with the available categories for the user to select:

namespace SomeSite.WebUI.Models
{
  public class EditBookModel
  {
    IRepository repository;

    public EditBookModel() { }

    public IRepository Repository
    {
      get { return repository; }
      set { this.repository = value; }
    }

    public EditBookModel(IRepository Repository)
    {
      repository = Repository;
      this.Book = new Book();
      populateCategories();
    }

    public EditBookModel(IRepository Repository, 
                        int BookID)
    {
      repository = Repository;
      this.Book = repository.Books
        .First(x => x.BookID == BookID);
      populateCategories();
    }

    private void populateCategories()
    {
      PresentedCategories = 
        new List<SelectableItem>();

      foreach (Category Category in 
               repository.Categories)
        PresentedCategories.Add(new SelectableItem {
          ItemID = Category.CategoryID,
          ItemName = Category.CategoryName, 
          IsSelected = Book.BookCategories
            .Any(x => x.CategoryID == 
                         Category.CategoryID)
        });
    }

    public void Update()
    {
      repository.Save<Book>(this.Book);

      var selectedCategories = PresentedCategories
        .Where(x => x.IsSelected);

      // delete any assigned Categories 
      // that have not been selected
      foreach (var BookCategory in 
              this.Book.BookCategories)
        if (!selectedCategories
            .Any(x => x.ItemID == 
                         BookCategory.CategoryID))
          repository.Delete<BookCategory>(BookCategory);

      foreach (var selectedCategory 
               in selectedCategories)
      {
        // it's not any of the currently
        // assigned Categories
        if (!this.Book.BookCategories
             .Any(x => x.CategoryID == 
                          selectedCategory.ItemID))
        {
          // add it
          var BookCategory = new BookCategory
          {
            BookID = this.Book.BookID,
            CategoryID = selectedCategory.ItemID
          };
          repository.Save<BookCategory>(BookCategory);
        }
      }
    }
    
    public Book Book 
     { get; set; }
    public IList<SelectableItem> PresentedCategories 
     { get; set; }
  } 

  public class SelectableItem
  {
    public int ItemID { get; set; }
    public string ItemName { get; set; }
    public bool IsSelected { get; set; }
  }
}

Again, complex look but really pretty simple. Two constructors - one which takes a repository object only and to be sent to the view when adding a new book, the other which takes a repository object and book ID, to be called when updating an existing book. Both of these constructors call the "populateCategories()" function which iterates through the available categories populating a List collection of "SelectableItem" objects. It is these SelectableItem objects in the model which will be used to present the checkboxes. Note that we don't use a list of Category objects here - reason being we'd be sending unnecessary information (the other members in the Category class) to the browser and back. Using this approach the SelectableItem class can also be reused for areas where other lists of checkboxes are required.

Lastly there's an "Update()" public method which iterates through the existing categories associated with the book and removes any BookCategory records from the database that have not been selected, and then iterates through the selected categories in the SelectableItem list, creating new BookCateogory records which do not exist.

Next up is the view. There's actually two of them - one to display the edit book web form, another to dictate how each instance of a "SelectableItem" object should appear. First off, the EditBook.cshtml web form:

@model SomeSite.WebUI.Models.EditBookModel

<h2>Admin : Edit Book @Model.Book.BookName</h2>
@using (Html.BeginForm("EditBook", "Admin")) { 
  @Html.ValidationSummary() 
  @Html.HiddenFor(x => x.Book.AccountID) 
  @Html.EditorFor(x => x.Book)
  @Html.EditorFor(x => x.PresentedCategories) 
  <input type="submit" value="Save" /> 
  @Html.ActionLink("Cancel and return to List", 
                   "Book") 
} 

Now, the SelectableItem.cshtml file. This needs to go under a "EditorTemplates" sub-directory under the "Views" directory where the EditBook.cshtml resides in the project, or in the "/Views/Shared/EditorTemplates" directory (more good info on these EditorTemplate models can be found here):

@model SomeSite.WebUI.Models.SelectableItem
<div>
  @Html.HiddenFor(x => x.ItemID)
  @Html.CheckBoxFor(x => x.IsSelected)
  @Html.LabelFor(x => x.IsSelected, Model.ItemName) 
</div>

Lastly, the controller which implements the "EditBook" methods:

namespace SomeSite.WebUI.Controllers
{
  // DI-style constructors which accept 
  // the "repository" object go here
  
  public class AdminController : Controller
  {
	public ViewResult AddBook()
    {
      return View("EditBook", new Book());
    }

    public ViewResult EditBook(int BookID)
    {
      var editedBook = 
        new EditBookModel(repository, BookID);
      return View(editedBook);
    }

    [HttpPost]
    public ActionResult 
      EditBook(EditBookModel EditedBook)
    {
      if (ModelState.IsValid)
      {
        EditedBook.Repository = repository;
        EditedBook.Update();
        return RedirectToAction("Index");
      }
      else
      {
        return View(EditedBook);
      }
    }
  }
}

As you can see, the controller is also relatively simple as the majority of work updating the entity classes is done within the EditBookModel class.

Okay, so it's not quite as simple as placing a CheckBoxList control on a webform, which is populated with a few lines within a "codebehind" file. However it's much lighter than than the Web Forms approach, and the code above can easily be engineered for reuse.

All wonderful friendly feedback appreciated :)

Comments are closed on this post.