11

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.

Community
  • 1
  • 1
René Kåbis
  • 842
  • 2
  • 9
  • 28
  • Does `City` contain a property `Region`? –  May 11 '16 at 01:32
  • Yes, City has `public virtual Region Region { get; set; }` at the very bottom. The relationship is also laid out in `IdentityModels.cs` using FluentValidation. – René Kåbis May 11 '16 at 01:37
  • The model you are seeing above is another model entirely, `CreateClientViewModel`. It is for creating a client in the Admin section. I created it so that drop-down menus and the like would be auto-populated without needing `ViewBag`, so that I can actually apply FluentValidation for client-side validation. – René Kåbis May 11 '16 at 01:38
  • All you need is `new SelectList(db.City.Where(x => x.Active == true), "CityId", "CityName", "Region.RegionName", null)` to generate your `IEnumerable` (but please do this is the controller, not your veiw model) –  May 11 '16 at 01:40
  • If I use a `ViewBag` to bring in the drop-down list, FluentValidation on the client side ceases to function. As such, I have to populate all my drop-down menus from the Model, attach the model to the view during page load `View(model)`, and hook them in through the View. If I need to validate a result, I avoid `ViewBag` whenever possible. – René Kåbis May 11 '16 at 01:42
  • I'm not saying to use `ViewBag`. The property in your view model should be `public IList CityName { get; set; }` and in the controller use `model.CityName = new SelectList(...` (the code in my comment above)`. A view model should never contain database access code. That is the responsibility of the controller. –  May 11 '16 at 01:44
  • I commented out that entire section of Model and replaced it with `public IEnumerable CityName { get; set; }` and made the additions to the controller. Now I get `cannot initialize type 'CreateClientViewModel' with a collection initializer because it does not implement 'System.Collections.IEnumerable'` – René Kåbis May 11 '16 at 01:50
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/111587/discussion-between-stephen-muecke-and-rene-kabis). –  May 11 '16 at 01:52

2 Answers2

21

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);
  • @ Stephen how do you make this multiselect – Dan Hunex Aug 21 '18 at 20:43
  • @DanHunex, You just use `@Html.ListBoxFor(x => x.CityId, Model.CityList , new { @class = "form-control" })` instead of `DropDownListFor()` –  Aug 21 '18 at 23:38
  • This is old, but something you said made me confused. You stated there can be no database access even for something like SelectListItems in the ViewModel. How would you move it to the Controller without having duplicate code for every place that ViewModel is loaded? – Michael Ziluck Sep 27 '18 at 15:37
  • @MichaelZiluck. Typically you refactor it to a separate method/service. For example in [this DotNetFiddle](https://dotnetfiddle.net/1bPZym) for cascading dropdownlists, note the `ConfigureViewModel()` method - it means you are not duplicating code since that code would typically be called in both Create and Edit methods, and the GET method for each, and again in the POST method for each if `ModelState` is invalid and you needed to return the view –  Sep 27 '18 at 22:50
  • That's neat. I'm going to actually move a few things around after that. I had unit tests set up for what I was working on, but this actually made me realize there was an aspect that I didn't have fully tested. Thanks! – Michael Ziluck Sep 30 '18 at 00:25
0

I used this brilliant library: DropDownList Optgroup MVC 1.0.0

Nuget link

This is how to use it:

  1. Get your list ready:

    var ethnicityData = new List<GroupedSelectListItem> {
            new GroupedSelectListItem() { Value="British", Text ="British", GroupName = "White", GroupKey="1"},
            new GroupedSelectListItem() { Value="Irish", Text ="Irish", GroupName = "White", GroupKey="1"},
            new GroupedSelectListItem() { Value="White Other", Text ="White Other", GroupName = "White", GroupKey="1"},
            new GroupedSelectListItem() { Value="Other Ethin Group", Text ="Other Ethin Group", GroupName = "Other Ethnic Group", GroupKey="2"},
            new GroupedSelectListItem() { Value="Chinese", Text ="Chinese", GroupName = "Other Ethnic Group", GroupKey="2"},
            new GroupedSelectListItem() { Value="Other mixed background", Text ="Other mixed background", GroupName = "Mixed", GroupKey="4"},
            new GroupedSelectListItem() { Value="White and Asian", Text ="White and Asian", GroupName = "Mixed", GroupKey="4"},
            new GroupedSelectListItem() { Value="White and Black African", Text ="White and Black African", GroupName = "Mixed", GroupKey="4"},
            new GroupedSelectListItem() { Value="White and Black Caribbean", Text ="White and Black Caribbean", GroupName = "Mixed", GroupKey="4"},
            new GroupedSelectListItem() { Value="Other Black background", Text ="Other Black background", GroupName = "Black or Black British", GroupKey="5"},
            new GroupedSelectListItem() { Value="Caribbean", Text ="Caribbean", GroupName = "Black or Black British", GroupKey="5"},
            new GroupedSelectListItem() { Value="African", Text ="African", GroupName = "Black or Black British", GroupKey="5"},
            new GroupedSelectListItem() { Value="Bangladeshi", Text ="Bangladeshi", GroupName = "Asian or Asian British", GroupKey="6"},
            new GroupedSelectListItem() { Value="Other Asian background", Text ="Other Asian background", GroupName = "Asian or Asian British", GroupKey="6"},
            new GroupedSelectListItem() { Value="Indian", Text ="Indian", GroupName = "Asian or Asian British", GroupKey="6"},
            new GroupedSelectListItem() { Value="Pakistani", Text ="Pakistani", GroupName = "Asian or Asian British", GroupKey="6"},
            new GroupedSelectListItem() { Value="Not Stated", Text ="Not Stated", GroupName = "Not Stated", GroupKey="3"}
    

    };

  2. Use it like this:

    @Html.DropDownGroupListFor(model => model.Ethnicity,ethnicityData, "", new { @class = "form-control" })

t_plusplus
  • 4,079
  • 5
  • 45
  • 60
  • 1
    “Last Published: 2014-01-27” -- Yeeeeahhhh… thanks but no thanks. Everything has bugs, and anything being actively maintained will be updated on a regular basis; I am not interested in abandonware. – René Kåbis Apr 10 '17 at 23:59
  • 1
    This package doesn't render unobtrusive validation attributes unless you explicitly specify them. – Emyr Jan 10 '18 at 10:32