Associating Related Items in a Collection via a Listbox

I get a lot of my inspiration for what I write about on this blog from StackOverflow questions I see asked over and over and over again, in various forms. Today's greatest hit is using a listbox control to select related items for a collection property.

For this example, we'll assume the following entities:

public class Employee
{
    public int Id { get; set; }

    public string Name { get; set; }
}

public class Company
{
    public int Id { get; set; }

    public string Name { get; set; }

    public virtual ICollection<Employee> Employees { get; set; }
}

Our example will center around create and edit views for Company that allow selecting employees that belong to a company.

First and foremost, for this in particular, you need a view model. If you've been avoiding view models up to this point, well, now is the time to become acquainted. Frankly, it's just not possible to do this with just your database-backed entity, alone. Let's create one now:

public class CompanyViewModel
{
    [Required]
    public string Name { get; set; }

    [Required]
    [Display(Name = "Employees")]
    public List<int> EmployeeIds { get; set; }

    public IEnumerable<SelectListItem> EmployeeChoices { get; set; }
}

Two important points about this view model:

  1. The view model only contains properties on the Company entity that need to be editable or otherwise utilized in the view. The Id property is unecessary because in the case of creating a company, it will be assigned automatically by the database, and in the case of editing a company, it will be passed in the URL.

  2. The collection property Employees has been replace in the view model by two properties, EmployeeIds and EmployeeChoices. The former is a list of ints as it will hold just the ids of the associated Employee instances. The latter property is to hold the options for the listbox, because ViewBag is evil - don't use ViewBag.

Now, let's get into the controller code. Our first item on the to-do list here is to create a private method to populate the employee choices. This will need to be done in a total of 4 different actions, and any time you have code that will be repeated, you should factor that out into something reusable.

public class CompanyController : Controller
{
    private readonly ApplicationContext db = new ApplicationContext();

    private void PopulateEmployeeChoices(CompanyViewModel model)
    {
        model.EmployeeChoices = db.Employees.Select(m => new SelectListItem
        {
            Value = m.Id.ToString(),
            Text = m.Name
        };
    }

This is pretty straight-forward: we're just pulling all the employees from the database and constructing an enumerable of SelectListItem instances.

Now, let's start with the create actions:

public ActionResult Create()
{
    var model = new CompanyViewModel();

    PopulateEmployeeChoices(model);
    return View(model);
}

This action is extremely simple. We're just newing up an instance of our view model, populating the employee choices for our eventual listbox and sending the whole shebang to the view.

Speaking of the view, here's our Create.cshtml:

@model ListBoxExample.ViewModels.CompanyViewModel

@using (Html.BeginForm())
{
    <div>
        @Html.LabelFor(m => m.Name)
        @Html.TextBoxFor(m => m.Name)
    </div>

    <div>
        @Html.LabelFor(m => m.EmployeeIds)
        @Html.ListBoxFor(m => m.EmployeeIds, Model.EmployeeChoices)
    </div>

    <button type="submit">Submit</button>
}

Now, on to what happens on post:

[HttpPost]
public ActionResult Create(CompanyViewModel model)
{
    if (ModelState.IsValid)
    {
        // Create a new `Company` and map over property
        // values from the view model
        var company = new Company
        {
            Name = model.Name,
            Employees = db.Employees.Where(m => model.EmployeeIds.Contains(m.Id))
        }

        db.Companies.Add(company);
        db.SaveChanges();

        return RedirectToAction("Index");
    }

    PopulateEmployeeChoices(model);
    return View(model);
}

Let's look at some key points in this code:

  1. We're "mapping" values from our view model to values on a Company instance. We can't save the view model directly to the database because it's not tracked by Entity Framework, so we map its values onto values on Company, which we can save.

  2. For the Employees collection, we need actual Employee instances, but all we get from the posted data is a list of ids. As a result, we use that list of ids to query the right Employee instances from the database.

  3. In the case where the model fails validation, the user is simply returned to the view so they can edit the form to correct any errors. However, notice that before we send them back, we again call PopulateEmployeeChoices. This is because this was not posted along with the data of the form. If we didn't repopulate the choices, then when the user gets returned to the view, the listbox would be empty. Remember that only the properties on your view model that have HTML fields and are posted will have values. Anything else, you'll need to repopulate.

Moving right along, let's edit the company now:

public ActionResult Edit(int id)
{
    var company = db.Companies.Find(id);
    if (company == null)
    {
        return new HttpNotFoundResult();
    }

    // map properties from company onto view model
    var model = new CompanyViewModel
    {
        Name = company.Name,
        EmployeeIds = company.Employees.Select(m => m.Id)
    }

    PopulateEmployeeChoices(model);
    return View(model);
}

Here, since we're dealing with an existing company, we need to map the properties from that company to a CompanyViewModel instance that we can use for our view. Since, again, the view model only has an EmployeeIds property that takes a list of ids, we use a little LINQ to just select the ids from the Employees collection on the company.

I'll forgo the view code for Edit.cshtml since in this example it's exactly the same as the view code for Create.cshtml above. Instead, let's dig into the post Edit action, which has a little surprise in store for us.

[HttpPost]
public ActionResult Edit (int id, CompanyViewModel model)
{
    var company = db.Companies.Find(id);
    if (company == null)
    {
        return new HttpNotFoundResult();
    }

    if (ModelState.IsValid)
    {
        company.Name = model.Name;

        // You might be tempted to just do the following:
        // company.Employees = db.Employes.Where(m => model.EmployeeIds.Contains(m.Id));
        // However, we have to do some extra footwork, unfortunately.

        // First, we need to remove any employees that were
        // de-selected by the user
        company.Employees.Where(m => !model.EmployeeIds.Contains(m.Id))
            .ToList()
            .ForEach(m => company.Employees.Remove(m));

        // Now, we add any new employees that were previously not selected
        var existingEmployeeIds = company.Employees.Select(m => m.Id);
        var newEmployeeIds = model.EmployeeIds.Except(existingEmployeeIds);
        db.Employees.Where(m => newEmployeeIds.Contains(m.Id))
            .ToList()
            .ForEach(m => company.Employees.Add(m));

        db.Entry(company).State = EntityState.Modified;
        db.SaveChanges();

        return RedirectToAction("Index");
    }

    PopulateEmployeeChoices(model);
    return View(model);
}

As you can see, the edit post action is a tad more complicated. If we just set the Employees collection on the company to a new list, it looks to Entity Framework like we're adding all those employees to the collection. Since some of those might have already been associated, when it comes time to insert the data into the database, it could fail because those relationship will be duplicated. Individually removing the relationships that are no longer valid and explicitly adding just the new relationships, we side-step the issue.

Hopefully that will help clear things up for everyone confused about how to handle this situation. If anything is unclear or you would just like some extra information about anything, feel free to hit me up in the comments below.

comments powered by Disqus