René Kåbis René Kåbis - 12 days ago 7
ASP.NET (C#) Question

Constructing a Select List with OptGroup groups

Current project:


  • ASP.NET 4.5.2

  • MVC 5



I am trying to build a select menu with
OptGroups
from the Model, but my problem is that I cannot seem to build the
OptGroups
themselves.

My model:

[DisplayName("City")]
public string CityId { get; set; }
private IList<SelectListItem> _CityName;
public IList<SelectListItem> CityName {
get {
List<SelectListItem> list = new List<SelectListItem>();
Dictionary<Guid, SelectListGroup> groups = new Dictionary<Guid, SelectListGroup>();
List<Region> region = db.Region.Where(x => x.Active == true).OrderBy(x => x.RegionName).ToList();
foreach(Region item in region) {
groups.Add(item.RegionId, new SelectListGroup() { Name = item.RegionName });
}
List<City> city = db.City.Where(x => x.Active == true).ToList();
foreach(City item in city) {
list.Add(new SelectListItem() { Text = item.CityName, Value = item.CityId.ToString(), Group = groups[item.RegionId] });
}
return list;
}
set { _CityName = value; }
}


Each city can be in a region. I want a select menu to group the cities by region. By all that I can figure out, the code above is supposed to do the job, but instead I get a drop-down menu with all cities grouped under the OptGroup named
System.Web.Mvc.SelectListGroup


The key thing in the above code is that I first iterate through the Regions, and put them into a Dictionary, with the
RegionId
set to be the key that brings back the
RegionName
(which itself is formatted as a SelectListGroup).

Then I iterate through the Cities, and assign to each city the group that matches the city’s
RegionId
.

I have not seen any examples on the Internet that actually pull content from a database -- 100% of all examples use hard-coded
SelectListGroup
and
SelectListItem
values.

My View is also correct, AFAIK:

@Html.DropDownListFor(x => x.CityId, new SelectList(Model.CityName, "Value", "Text", "Group", 1), "« ‹ Select › »", htmlAttributes: new { @class = "form-control" })


As you can see, the Group is supposed to be brought into the
SelectList
, and the
DropDownList
is being created with
OptGroups
, just not the correct ones.

My resulting drop-down menu looks something like this:

« ‹ Select › »
System.Web.Mvc.SelectListGroup
City1
City2
...
LastCity


When it should be like this:

« ‹ Select › »
Region1
City2
City4
City5
Region2
City3
City1
City6


Suggestions?




Modified solution: I have followed the solution provided by Stephen Muecke, but have modified it slightly.

One of the general rules of MVC is that you have a model that is heavier than the controller, and that the model defines your business logic. Stephen asserts that all database access should be done in the controller. I have come to agree with both.

One of my biggest “issues” is that any creation of a drop-down menu or any other pre-populated form element needs to be called every time the page is called. That means, for either a creation or edit page, you need to call it not only on the [HttpGet] method, but also in the [HttpPost] method where the model is sent back to the view because it did not properly validate. This means you have to add code (traditionally via ViewBags) to each Method, just to pre-populate elements like drop-down lists. This is called code duplication, and is not a Good Thing. There has to be a better way, and thanks to Stephen’s guidance, I have found one.

The problem with keeping data access out of the Model is that you need to populate your model with the data. The problem with avoiding code reuse and avoiding potential errors is that you should not do the job of binding data to elements in the controller. This latter action is business logic, and rightfully belongs in the model. The business logic in my case is that I need to limit user input to a list of cities, grouped by region, that the administrator can select from a drop-down. So while we might assemble the data in the controller, we bind it to the model in the model. My mistake before was doing both in the model, which was entirely inappropriate.

By binding the data to the model in the model, we avoid having to bind it twice - once in each of the [HttpGet] and [HttpPost] methods of the controller. We only have to bind it once, in the model that is handled by both methods. And if we have a more generic model that can be shared between Create and Edit functions, we can do this binding in only one spot instead of four (but I don’t have this degree of genericness, so I won’t give that as an example)

So to start out, we actually peel off the entire data-assembly, and stick it inside its own class:

public class SelectLists {
public static IEnumerable<SelectListItem> CityNameList() {
ApplicationDbContext db = new ApplicationDbContext();
List<City> items = db.City.Where(x => x.Active == true).OrderBy(x => x.Region.RegionName).ThenBy(x => x.CityName).ToList();
return new SelectList(items, "CityId", "CityName", "Region.RegionName", 1);
}
}


This exists within the namespace, but beneath the controller of the section we are dealing with. For clarity’s sake, I stuck it at the very end of the file, just before the closing of the namespace.

Then we look at the model for this page:

public string CityId { get; set; }
private IEnumerable<SelectListItem> _CityName;
public IEnumerable<SelectListItem> CityName {
get { return SelectLists.CityNameList(); }
set { _CityName = value; }
}


Note: Even though the
CityId
is a
Guid
and the DB field is a
uniqueidentifier
, I am bringing this value in as a string through the view because client-side validation sucks donkey balls for Guids. It’s far easier to do client-side validation on a drop-down menu if the
Value
is handled as a string instead of a Guid. You just convert it back into a Guid before you plunk it back into the master model for that table. Plus, CityName is not an actual field in the City table - it exists purely as a placeholder for the drop-down menu itself, which is why it exists in the
CreateClientViewModel
for the Create page. That way, in the view we can create a
DropDownListFor
that explicitly binds the CityId to the drop-down menu, actually allowing client-side validation in the first place (Guids are just an added headache).

The key thing is the
get {}
. As you can see, no more copious code which does DB access, just a simple
SelectLists
which targets the class, and a calling of the method
CityNameList()
. You can even pass variables on to the method, so you can have the same method bring back different variations of the same drop-down menu. Say, if you wanted one drop-down on one page (Create) to have its options grouped by OptGroups, and another drop-down (Edit) to not have any groupings of options.

The actual model ends up being even simpler than before:

@Html.DropDownListFor(x => x.CityId, Model.CityName, "« ‹ Select › »", htmlAttributes: new { @class = "form-control" })


No need to modify the element that brings in the drop-down list’s data -- you just call it via
Model.ElementName
.

I hope this helps.

Answer

Firstly, you view model should not contain database access code to populate its properties. That is the responsibility of the controller and you have made your code impossible to unit test. Start by changing the model to

public class CreateClientViewModel
{
    [DisplayName("City")]
    public string CityId { get; set; }
    public IList<SelectListItem> CityList { get; set; }
    ....
}

Then in the controller, you can use one of the overloads of SelectList that accepts a groupName to generate the collection

var cities = var cities = db.City.Include(x => x.Region).Where(x => x.Active == true)
    .OrderBy(x => x.Region.RegionName).ThenBy(x => x.CityName);

var model = new CreateClientViewModel()
{
    CityList = new SelectList(cities, "CityId", "CityName", "Region.RegionName", null, null)
};
return View(model);

And in the view

@Html.DropDownListFor(x => x.CityId, Model.CityList , "« ‹ Select › »", new { @class = "form-control" })

As an alternative, you can also do this using the Group property of SelectListItem

var model = new CreateClientViewModel()
{
    CityList = new List<SelectListItem> // or initialize this in the constructor
};
var cities = var cities = db.City.Include(x => x.Region).Where(x => x.Active == true).GroupBy(x => x.Region.RegionName);
foreach(var regionGroup in cities)
{
    var optionGroup = new SelectListGroup() { Name = regionGroup.Key };
    foreach(var city in regionGroup)
    {
        model.CityList.Add(new SelectListItem() { Value = city.CityId.ToString(), Text = city.CityName, Group = optionGroup });
    }
}
return View(model);
Comments