About Us

Creating a Cascading Dropdown in ASP.net MVC 3 and jQuery

by Travis Ellis 5. August 2011 09:00

One of the common tasks that comes up when developing web applications is working with dependent data. If you have a form in your application that asks the user to select the Make and Model of their car, then you need to refresh the list of Models every time the Make changes. In ASP.net Web Forms this was commonly done by setting the AutoPostBack property of the DropDownList control and creating a SelectedIndexChanged event handler. The problem with this approach is that the page does a full postback, which can often be inefficient and can appear slow to the user. Fortunately, a better method exists in ASP.net MVC that is easy to accomplish with jQuery.

In order to show you how to create a cascading dropdown I am going to create a very simple web application. It will have a single page that will have two dropdowns, one for a list of States and one for a list of Cities. The list of Cities will change whenever the State changes. Here is the main controller for our application:

public class HomeController : Controller
{
    private ILocationRepository locationRepository = new LocationRepository();

    public ActionResult Index()
    {
        var model = new IndexViewModel();

        model.AvailableStates = new SelectList(locationRepository.GetStates(), "Abbreviation", "Name");
        model.AvailableCities = new SelectList(locationRepository.GetCities(), "Id", "Name");

        return View(model);
    }
}

public class IndexViewModel
{
    public IndexViewModel()
    {
        AvailableStates = new SelectList(Enumerable.Empty<State>(), "Abbreviation", "Name");
        AvailableCities = new SelectList(Enumerable.Empty<City>(), "Id", "Name");
    }

    public string State { get; set; }
    public int City { get; set; }

    public SelectList AvailableStates { get; set; }
    public SelectList AvailableCities { get; set; }
}

The view for our page will be extremely simple. It will consist of the two dropdown lists needed to display the data and a bit of javascript used to retrieve the data.

@model CascadingDropdowns.Models.IndexViewModel
@using (Html.BeginForm())
{
    <div class="editor-label">
        @Html.LabelFor(m => m.State)
    </div>
    <div class="editor-field">
        @Html.DropDownListFor(m => m.State, Model.AvailableStates, new { style = "width: 150px" })
    </div>
    
    <div class="editor-label">
        @Html.LabelFor(m => m.City)
    </div>
    <div class="editor-field">
        @Html.DropDownListFor(m => m.City, Model.AvailableCities, new { style = "width: 150px" })
    </div>
    
    <p>
        <input type="submit" value="Submit" />
    </p>
}

I am holding off on posting the javascript code until I post the code to retrieve the locations. The view is pretty simple. It defines a label and a dropdown for both the State and City property of our model. It uses the AvailableStates and AvailableCities properties as the data source for the dropdowns. It also contains a Submit button to POST the form, but this example won't be doing anything when the form is posted.

Here is the controller we will use to retrieve our location data. It has two action methods that both return JSON formatted data. The first method will return a list of all states. The other method will retrieve all of the cities based on the state abbreviation that is passed in.

public class LocationsController : Controller
{
    private ILocationRepository locationRepository = new LocationRepository();

    [HttpPost]
    public ActionResult States()
    {
        var states = locationRepository.GetStates();
            
        return Json(new SelectList(state, "Id", "Name"));
    }

    [HttpPost]
    public ActionResult Cities(string abbreviation)
    {
        var cities = locationRepository.GetCities(abbreviation);

        return Json(new SelectList(cities, "Abbreviation", "Name")); 
    }
}

Here is the corresponding repository class that will retrieve the data. I am going to hard code the data to simplify the example, although it would be easy to hook up this code to a database using your favorite data access tool such as Entity Framework or LINQ to SQL.

public class LocationRepository : ILocationRepository
{
    public IQueryable<State> GetStates()
    {
        return new List<State>
        {
            new State { Abbreviation = "NE", Name = "Nebraska" },
            new State { Abbreviation = "NC", Name = "North Carolina" }
        }.AsQueryable();
    }

    public IQueryable<City> GetCities(string abbreviation)
    {
        var cities = new List<City>();
 
        if (abbreviation == "NE")
        {
            cities.AddRange(new List<City> {
                new City { Id = 1, Name = "Omaha" },
                new City { Id = 2, Name = "Lincoln" }
            });
        }
        else if (abbreviation == "NC")
        {
            cities.AddRange(new List<City> {
                new City { Id = 3, Name = "Charlotte" },
                new City { Id = 4, Name = "Raleigh" }
            });
        }

        return cities.AsQueryable();
    }
}

public interface ILocationRepository
{
    IQueryable<State> GetStates();
    IQueryable<City> GetCities(string abbreviation);
}

Now that we have a controller action that will return the data we need, it is time to wrap it all up with our jQuery code. You might have noticed that our controller actions have been marked with the [HttpPost] attribute. This will prevent the browser from caching the data, and MVC doesn't allow JSON GET requests by default. Here is the jQuery code:

<script type="text/javascript" src="/Scripts/jquery-1.5.1.min.js"></script>
<script type="text/javascript">
    function getCities(abbr) {
        $.ajax({
            url: "@Url.Action("Cities", "Locations")",
            data: {abbreviation: abbr},
            dataType: "json",
            type: "POST",
            error: function() {
                alert("An error occurred.");
            },
            success: function(data) {
                var items = "";
                $.each(data, function(i, item) {
                    items += "<option value=\"" + item.Value + "\">" + item.Text + "</option>";
                });

                $("#City").html(items);
            }
        });
    }

    $(document).ready(function(){
        $("#State").change(function() {
            var abbr = $("#State").val();

            getCities(abbr);
        });
    });
</script>

That is all the code that is required to get a cascading dropdown. The jQuery code sets up an event handler that will run whenever the selected item is changed, making an AJAX call that is expecting JSON formatted data. When the AJAX call successfully completes it iterates over the collection of objects building an <option> element for each one and sets the HTML of the dropdown to the new list of items. It would be trivial to replace the code in the repository to retrieve data from a database as well, but I will leave that as an exercise for the reader.

SEO Friendly Routes with ASP.net MVC 3

by Travis Ellis 14. March 2011 16:35

One of the most important things for a modern day website is for it to be easily found. If nobody can find your website, then it isn't very useful. One thing you can do to help is by creating URLs that contain useful information. Google will usually rank a site higher with a URL like http://www.northwind.com/products/3/aniseed-syrup compared to http://www.northwind.com/products/3. In addition to being ranked higher by search engines, it is a lot easier for humans to remember as well. I am going to show you an easy way to create these SEO-style routes for your ASP.net MVC 3 applications.

The first thing we need to do in order to get this to work is create a route that will match this pattern:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.MapRoute(
        "ProductDetails",
        "products/{productId}/{productName}", // URL
        new { controller = "Products", action = "Details", productName = UrlParameter.Optional }, // URL Defaults
        new { id = @"\d+" } // URL Constraints
    );
}

What this does is register a route in our application that matches the pattern /products/id/ and optionally /products/id/name. The product name is optional but the id is required and must be an integer.

Now that we have our route in place we need to create the controller that will handle requests that match the route:

public class ProductsController : Controller
{
    public ActionResult Index(int id, string productName)
    {
        // retrieve the product from the database
        Product product = db.Products.Single(p => p.ProductID == id);

        return View(product);
    }
}

This controller action will take in an id and a product name, retrieve a product based on the id and then return the view, passing in the Product data. You might have noticed that the controller isn't using the productName at the moment. Currently, if we have a product that contains spaces, the URL will encode the string like so:

http://www.northwind.com/products/3/aniseed%20syrup

This is far from ideal. Let's go ahead and fix that problem.

public static class StringHelpers
{
    public static string ToSeoUrl(this string url)
    {
        // make the url lowercase
        string encodedUrl = (url ?? "").ToLower();

        // replace & with and
        encodedUrl = Regex.Replace(encodedUrl, @"\&+", "and");

        // remove characters
        encodedUrl = encodedUrl.Replace("'", "");

        // remove invalid characters
        encodedUrl = Regex.Replace(encodedUrl, @"[^a-z0-9]", "-");

        // remove duplicates
        encodedUrl = Regex.Replace(encodedUrl, @"-+", "-");

        // trim leading & trailing characters
        encodedUrl = encodedUrl.Trim('-');

        return encodedUrl;
    }
}

The above code creates an extension method for the string type and returns an "SEO-encoded" URL using the following steps:

  1. Convert the string to lowercase.
  2. Replace the & symbol with the word and
  3. Remove unwanted characters by replacing with a space (currently only ')
  4. Replace all other characters other than letters/numbers with a -
  5. Remove duplicate -'s from the string (we don't want aniseed--syrup, etc.)
  6. Remove leading/trailing -'s from the string

Now, a string like "AnIseeD Syrup" will be converted to "aniseed-syrup". Now we can modify our controller to make use of this functionality.

public class ProductsController : Controller
{
    public ActionResult Index(int id, string productName)
    {
        // retrieve the product from the database
        Product product = db.Products.Single(p => p.ProductID == id);

        // make sure the productName for the route matches the encoded product name
        string expectedName = product.ProductName.ToSeoUrl();
        string actualName = (productName ?? "").ToLower();

        // permanently redirect to the correct URL
        if( expectedName != actualName )
        {
            return RedirectToActionPermanent("Index", "Products", new { id = product.ProductID, productName = expectedName });
        }
        
        return View(product);
    }
}

This updated controller will do an HTTP 301 redirect if the product name in the URL does not match what it should be, or if it wasn't provided at all. A 301 redirect will tell a search engine to permanently re-index the content. If the user types http://www.northwind.com/products/3 or http://www.northwind.com/products/3/aniseed or any other URL other than http://www.northwind.com/products/3/aniseed-syrup then it will automatically redirect them to the correct one.

The final piece to wire this all up is to make sure your views are creating the proper links. We don't want the requests to redirect by default, otherwise every request will make two calls to the database. Here I am going to use the new MVC 3 View Engine Razor, but it could be done just as easily using the Web Forms View Engine.

@model IEnumerable<Models.Product>
@using Mvc.Helpers

<table>
   <tr>
      <th>Edit</th>
      <th>Product Name</th>
   </tr>
   
   @foreach( var product in Model )
   {
      <tr>
         <td>@Html.ActionLink("Edit", "Details", new { id = product.ProductID, productName = product.ProductName.ToSeoUrl() })</td>
         <td>@product.ProductName</td>
      </tr>
   }
</table>

That's all you need to do. Create an ActionLink and make sure you pass the productName value by calling our ToSeoUrl() helper. Hopefully you found this technique as helpful as I did.