How to Create Cascading Dropdowns in ASP.NET MVC

Today, in our third post in the series, I demonstrate three techniques to setup cascading dropdowns. Some good and some bad.

Written by Jonathan "JD" Danylko • Last Updated: • MVC •

UI Design on a Desk

The Ultimate Guide to Dropdowns in ASP.NET MVC Series

We've all seen this scenario before.

You want to filter the user's selection by using one dropdown to filter out the second dropdown. Some take it even further where you have a third dropdown for filtering.

While there is a builder pattern, this doesn't solve the immediate visual filtering that the user wants to experience.

To make matters worse, we are in this day-and-age where if your page does a postback, it's considered a turn-off. Users are becoming savvy enough where a postback is considered a wart on the application. It ruins their user experience.

While cascading dropdowns are one of the most difficult ways of displaying data on a UI, today, I will take us down the path of working through how to create cascading dropdowns, or dynamic dropdowns, and provide two ways on how to implement them.

What's the approach?

As I mentioned, users want a fast experience. Heck, even Google wants the user to have a great experience.

So what would be the fastest approach to solving this issue?

We have two options: Send everything to the client or send a small chunk of data to the user and retrieve as needed on an Ad-hoc basis.

Three Ways of Implementation

In our example, we'll create three new pages: CascadingAllData, CascadingSignalR and CascadingWebAPI.

1. Send All Data to the Client (CascadingAllData.cshtml) - BAD

The whole reason I'm writing this particular page is to demonstrate how much client-side code we need to maintain.

To pull off this UI experience, we need to send the first year along with the make and models of the year. We already have a distinct list of years. That's not a problem.

The problem we have is with the make and models. We need the entire list to filter it out on selection of the year.

First, the ViewModel requires some adjustment. We need an additional SelectItemList for our Make and Model, but it's only for the initial load of the data.

Once we have the second dropdown generated, we'll use JavaScript to generate the Make and Model moving forward from here.

ViewModels/VehicleViewModel.cs

public class VehicleViewModel
{
    public IEnumerable<Vehicle> AllVehicles { get; set; }

    public int SelectedYear { get; set; }
    public IEnumerable<Vehicle> SelectedVehicles { get; set; }
    public IEnumerable<SelectListItem> GetVehicleYearSelectList(IEnumerable<Vehicle> vehicles, int defaultYear = 0)     {         return vehicles             .Distinct(new VehicleYearComparer())             .OrderBy(e => e.Year)             .Select((e, i) => new SelectListItem             {                 Text = e.Year.ToString(),                 Value = e.Year.ToString(),                 Selected = e.Year == defaultYear             });     }
    public IEnumerable<SelectListItem> GetVehicleMakeSelectList(IEnumerable<Vehicle> vehicles, int defaultYear = 0)     {         return vehicles             .Where(auto => auto.Year == defaultYear)             .OrderBy(e => e.Year)             .ThenBy(auto => auto.Make)             .Select((e, i) => new SelectListItem             {                 Text = $"{e.Make} {e.Model}",                 Value = $"{e.Make} {e.Model}"             });     } }

Our View will look like the following:

Views/Home/CascadingAllData.cshtml

<h3>Cascading All Data Example</h3>

@using (Html.BeginForm("CascadingAllData", "Home", FormMethod.Post, new { @class = "vehicle-form form-horizontal" })) {     <div class="form-group">         @Html.Label("Year", "Year:", new { @class = "col-sm-1 control-label" })         <div class="col-sm-2">             @Html.DropDownListFor(model => model.SelectedYear,                 Model.GetVehicleYearSelectList(Model.AllVehicles),                 new { @class = "vehicle-year form-control" })         </div>     </div>
    <div class="form-group">         @Html.Label("Make", "Make/Model:", new { @class = "col-sm-1 control-label" })         <div class="col-sm-2">             @Html.DropDownListFor(model => model.SelectedYear,                 Model.GetVehicleMakeSelectList(Model.SelectedVehicles, Model.SelectedYear),                 new { @class = "vehicle-make-model form-control" })         </div>     </div>
}
<script src="../../Scripts/jquery-3.1.1.min.js"></script> <script>     $(function() {         var vehicles = [             @@for (var i = 0; i < Model.AllVehicles.Count(); i++)             {                 var vehicle = Model.AllVehicles.ElementAt(i);                 <text>                     { VehicleYear: @@vehicle.Year, VehicleMake: "@@vehicle.Make", VehicleModel: "@@vehicle.Model" }                 </text>                 if (i < Model.AllVehicles.Count() - 1)                 {                     @@:,                 }             }         ];         $(".vehicle-year").change(             function(e) {
                // Get the year.                 var year = $(".vehicle-year").val();
                // get the make and models by year.                 var filtered = vehicles.filter(auto => auto.VehicleYear.toString() === year);
                // Empty the current options.                 var makeModel = $(".vehicle-make-model");                 $(makeModel).empty();
                $.each(filtered, function(key,value) {                     $(makeModel).append($("<option></option>")                         .attr("value", value.VehicleMake+" "+value.VehicleModel)                         .text(value.VehicleMake+" "+value.VehicleModel));                 });             });     }); </script>

The View is pretty standard except for the JavaScript portion.

This is probably on of the dirtiest ways of populating a JavaScript array using server-side code along with Razor (Go ahead...check the source code). Ughh...I think I need a shower. :-p

Below that, we grab the year from the dropdown and use that to filter the...uhh...huge array with records we need.

We set the attribute of the option we just created and set the text inside as well. If there was an Id for the vehicle, we could use that to populate the value attribute.

There's a couple of downsides to this particular technique.

  • It's not maintainable
  • If you have a huge list of records, that is a hefty load of records sent to the client. Heck, this is a lot with 800 records.

But there's an easy way to do this.

2. Sending the secondary dropdown data using SignalR (CascadingSignalR.cshtml) - BETTER

For those who know me, I'm a SignalR junkie. I've been building SignalR projects ever since I found out about it.

Since we already have some hooks in place for our second dropdown, integrating SignalR into this process should be a cake walk.

First, install SignalR. If you need to know how to install SignalR, you can view the pre-setup on my real-time Like Button project or view the SignalR docs.

Next, create a hub. We need a method to return the vehicles by year.

Hubs/AutoHub.cs

[HubName("autoHub")]
public class AutoHub : Hub
{
    public Task<IEnumerable<Vehicle>> GetAutosByYear(string year)
    {
        var connection = ConfigurationManager.ConnectionStrings["DropdownDatabase"].ToString();
        var repository = new VehicleRepository(connection);
        var taskResult = repository.GetAll().Where(auto => auto.Year.ToString() == year);

        var result = taskResult.Select(auto => new Vehicle {Make = auto.Make, Model = auto.Model});
        return Clients.Caller.updateMakeModel(result);     } }

Once we're done with our Hub, we can finish up with our View. As you notice, the only modifications we make are to our scripts.

Views/Home/CascadingSignalR.cshtml

@{
    ViewBag.Title = "Cascading Dropdown w/ SignalR";
}
@model DropDownDemo.ViewModels.VehicleViewModel

<h3>Cascading Dropdown w/ SignalR Example</h3>
@using (Html.BeginForm("CascadingSignalR", "Home", FormMethod.Post,      new { @class = "vehicle-form form-horizontal" })) {     <div class="form-group">         @Html.Label("Year", "Year:", new { @class = "col-sm-1 control-label" })         <div class="col-sm-2">             @Html.DropDownListFor(model => model.SelectedYear,                 Model.GetVehicleYearSelectList(Model.AllVehicles),                 new { @class = "vehicle-year form-control" })         </div>     </div>

    <div class="form-group">         @Html.Label("Make", "Make/Model:", new { @class = "col-sm-1 control-label" })         <div class="col-sm-2">             @Html.DropDownListFor(model => model.SelectedYear,                 Model.GetVehicleMakeSelectList(Model.SelectedVehicles, Model.SelectedYear),                 new { @class = "vehicle-make-model form-control" })         </div>     </div>
}
@section scripts {     <script src="../../Scripts/jquery.signalR-2.2.2.min.js"></script>     <script src="/signalr/hubs"></script>     <script>         $(function() {
            var aHub = $.connection.autoHub;
            aHub.client.updateMakeModel = function (makeModels) {                 // Empty the current options.                 var makeModel = $(".vehicle-make-model");                 $(makeModel).empty();                 $.each(makeModels, function (key, value) {                     $(makeModel).append($("<option></option>")                         .attr("value", value.Make + " " + value.Model)                         .text(value.Make + " " + value.Model));                 });             };
            $(".vehicle-year").change(                 function(e) {                     // Get the year.                     var year = $(this).val();                     aHub.server.getAutosByYear(year);                 });
            $.connection.hub.start()                 .done(function () {                     console.log('Now connected, connection ID='                         + $.connection.hub.id);                 })                 .fail(function() {                     console.log('Could not Connect!');                 });         });     </script> }

Let's take this script from the top.

We initialize the name of the hub by creating our variable, aHub. The connection is initialized by accessing the HubName Attribute in the AutoHub.cs at the top of the method. I use this to confirm that I'm calling it properly instead of getting confused by "am I calling it using camel-case or not?"

Any client-side calls make by SignalR by the server are declared using the <hubname>.client.xxxxx syntax. In this case, we declare an updateMakeModel function to update the dropdown with our data.

Next, we attach an event (onChange) to the Year dropdown. When the event is triggered, we make a call to the method GetAutosByYear() in our AutoHub.cs. Again, the signature for SignalR is <hubname>.server.xxxxxx. This method name is declared in your hub.

3. Sending the secondary dropdown data using WebAPI (CascadingWebAPI.cshtml) - BEST

This is probably the best way to incorporate any kind of cascading dropdowns. The WebAPI is entirely decoupled and returns Json structure with no strong-typing.

Since we have a LOT of these hooks already in place, we only need to make the WebAPI call and the scripts for View.

/VehiclesController.cs

public class VehiclesController : ApiController
{
    [HttpGet]
    public Vehicle[] GetByYear(string id)
    {
        var connection = ConfigurationManager.ConnectionStrings["DropdownDatabase"].ToString();
        var repository = new VehicleRepository(connection);
        var records = repository.GetAll().Where(e => e.Year.ToString() == id);
        return records
            .Select(e => new Vehicle { Make = e.Make, Model = e.Model })
            .ToArray();
    }
}

Of course, this is meant strictly for demonstration purposes. The configuration manager would be in a centralized place and the repository would be a private in the controller.

It's the same filtering as the SignalR example where we retrieve the vehicles based on the year which is represented by the id passed into the method.

So our API will look like this:

http://localhost:xxx/api/Vehicles/GetByYear/1958

Once the web app is running, you can test this in a browser address bar to get the JSON representation.

Views/Home/CascadingWebAPI.cshtml

@@section scripts
{
    <script>
        $(function() {

            $(".vehicle-year").change(                 function(e) {                     // Get the year.                     var year = $(this).val();                     var uri = "/api/vehicles/getbyyear/" + year;                     $.getJSON(uri)                         .done(function(data) {                             var makeModel = $(".vehicle-make-model");                             $(makeModel).empty();                             $.each(data,                                 function(key, value) {                                     $(makeModel).append($("<option></option>")                                         .attr("value", value.Make + " " + value.Model)                                         .text(value.Make + " " + value.Model));                                 });                         });                 });
        });     </script> }

Not too much to explain on this jQuery.

It's a simple Url call using getJSON and when it's "done," we execute the same script to clear and add the options to the dropdown.

Conclusion

In this third post about cascading dropdowns, we discussed a number of techniques on how to achieve this.

One way was to send all of the data over the wire and let JavaScript perform the filtering. Not a good technique because it pushes a LOT of data down to the client. It'd choke if it was a mobile app.

The second way was to use SignalR which is a little extreme since SignalR is meant for real-time two-way communication.

Finally, the third way to accomplish cascading dropdowns, which I recommend, is to create a web service using WebAPI. It allows a number of other clients to connect to it as well, not just a web page.

I guess I got a little ambitious with this week's post...

...but we're not done yet.

Did I miss a technique? Post a comment and let's discuss.

The Ultimate Guide to Dropdowns in ASP.NET MVC Series

ASP.NET 8 Best Practices on Amazon

ASP.NET 8 Best Practices by Jonathan Danylko


Reviewed as a "comprehensive guide" and a "roadmap to excellence" with over 120 Best Practices for ASP.NET Core 8, Jonathan's first book by Packt Publishing explores proven techniques for every phase of the SDLC.

Learn industry-standard concepts to improve your coding, debugging, and deployment of ASP.NET Core websites.

Order now on Amazon.com button

Picture of Jonathan "JD" Danylko

Jonathan "JD" Danylko is an author, web architect, and entrepreneur who's been programming for over 30 years. He's developed websites for small, medium, and Fortune 500 companies since 1996.

He currently works at Insight Enterprises as an Architect.

When asked what he likes to do in his spare time, he replies, "I like to write and I like to code. I also like to write about code."

comments powered by Disqus