Sunday, January 3, 2010

ASP.NET MVC Locale User Control (State/Province, Country)

A while back I wrote a locale control for ASP.NET that gave you a nice and easy way to plug in a cascading state/province – country control into your ASP.NET websites. Now that I have moved on to using ASP.NET MVC for my new projects I needed the same control, only this time in MVC. There were aspects of the previous version of the control that I didn’t like and wanted to refactor anyways, specifically the usage of an UpdatePanel (YUCK), reliance on PostBacks, no unit tests and poor separation of concerns for the presentation, logic and persistence layers. I now present to you the brand new ASP.NET MVC version of my Locale control with all of those issues addressed.

LocaleControlScreenShot

Overview of the ASP.NET MVC Locale User Control

  • ASP.NET MVC v1 User Control (Partial View)
  • jQuery handles the change events and retrieves new State/Province lists via a JSON call
  • Separation of concerns between the Presentation Layer, Logic Layer and Persistence Layer
  • Easily customizable data source via the LocaleDao persistence class
  • Unit tests with full dependency injection for all Controller and Logic methods

Basic Locale User Control Usage

Add the user control to your view:

<p>
    <% Html.RenderPartial("LocaleUserControl"); %>
</p>


Locale User Control View:




<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl" %>
<p>
    <label for="StatesProvinces">State / Province: </label>
    <%= Html.DropDownList("StatesProvinces") %>
</p>
<p>
    <label for="Countries">Country: </label>
    <%= Html.DropDownList("Countries") %>
</p>
<script type="text/javascript">
    $(function() {
        var countries = $("#Countries");
        var statesprovinces = $("#StatesProvinces");
        countries.change(function() {
            statesprovinces.find('option').remove();
            $.getJSON('/Base/StatesProvinces', { countryId: countries.val() }, function(data) {
                $(data).each(function() {
                    $("<option value=" + this.Id + ">" + this.Name + "</option>").appendTo(statesprovinces);
                });
            });
        });
    });
</script>






The BaseController class holds the generic methods to support this user control (and other generic shared user controls in your project.)




public class BaseController : Controller
    {
        // Dependency
        protected ILogicFactory _logicFactory;
        public ILogicFactory LogicFactory
        {
            get { return _logicFactory ?? new LogicFactory(); }
            set { _logicFactory = value; }
        }
        /// <summary>
        /// Gets the states provinces select list.
        /// </summary>
        /// <param name="country">The country to filter the states/provinces list by.</param>
        /// <param name="selectedStateProvince">The State/Province to select. Pass null if no default State/Province selected.</param>
        /// <returns></returns>
        public SelectList GetStatesProvincesSelectList(Country country, StateProvince selectedStateProvince)
        {
            // Create the object to return
            SelectList slSps = null;
            if (country != null)
            {
                // Get the collection of states and provinces
                List<StateProvince> statesProvinces = LogicFactory.LocaleLogic.GetStateProvincesByCountry(country.Id);
                
                // Build a selectlist from the statesprovinces collection
                if (statesProvinces != null)
                {
                    slSps = new SelectList(
                    statesProvinces.Select(s => new SelectListItem
                    {
                        Text = s.Name,
                        Value = s.Id.ToString()
                    })
                    , "Value", "Text", selectedStateProvince == null ? string.Empty : selectedStateProvince.Id.ToString());
                }
            }
            return slSps;
        }
        /// <summary>
        /// Gets the countries select list.
        /// </summary>
        /// <param name="selectedCountry">The country to select. Pass null if no default country selected.</param>
        /// <returns></returns>
        public SelectList GetCountriesSelectList(Country selectedCountry)
        {
            // Get the collection of countries, select United States by default
            List<Country> countries = LogicFactory.LocaleLogic.GetCountries();
            // Create the object to return
            SelectList slCountries = null;
            if (countries != null)
            {
                // Build a selectlist from the countries collection
                slCountries = new SelectList(
                countries.Select(c => new SelectListItem
                {
                    Text = c.Name,
                    Value = c.Id.ToString()
                })
                , "Value", "Text", selectedCountry == null ? string.Empty : selectedCountry.Id.ToString());
            }
            return slCountries;
        }
        [AcceptVerbs(HttpVerbs.Get)]
        public ActionResult StatesProvinces(int countryId)
        {
            // Returns JSON collection of stateprovince that are associated
            // with the countryId passed in
            List<StateProvince> sps = LogicFactory.LocaleLogic.GetStateProvincesByCountry(countryId);
            // Format the JSON return
            return Json(sps.Select(s => new
            {
                s.Id,
                s.Name
            }));
        }
    }


Data Source



The data source for the locale control is an XML file that is included in the App_Data folder. You might want to put this data into your database and then use a nice ORM like NHibernate to load the data up in the persistence layer rather than relying on the XML file. This is very easy to accomplish by changing the LocaleDao class, everything else should just work after you swap out the back end.



Unit Tests



I used Microsoft Visual Studio Tests to write unit tests for all of the controller methods and logic layer methods that had any sort of logic in them. I also used Moq to mock the dependencies in the methods under test and ensure the viability of the tests. You should be able move these unit tests into your project when you use the Locale user control. If you aren’t using the Visual Studio Test framework and are using NUnit instead, it is quite easy to change the syntax.



Download



You can download the full solution including the example MVC application, locale user control and unit tests below.




- Aaron

http://www.churchofficeonline.com

4 comments:

JP said...

Great tutorial! I was looking for something like this and your post came along just in time. Great Job!!!

Anonymous said...

TureMoupe
Bpgg

Anonymous said...

Thanks for this. I made a time zone offset selector adapted from your code.

However your example project when loaded into VS2010 & executed on IE8 doesn't work. The State field shows data on the first page load but when selecting another country or even going back to the initial country the states don't show. By viewing the page you can see that only the initial countries' states are in the page.

Anonymous said...

This project won't work on MVC 2.0 without the very slight modification below:-

[AcceptVerbs(HttpVerbs.Get)]
public ActionResult StatesProvinces(int countryId)
{
// Returns JSON collection of stateprovince that are associated
// with the countryId passed in
List sps = LogicFactory.LocaleLogic.GetStateProvincesByCountry(countryId);

// Format the JSON return
return Json(sps.Select(s => new
{
s.Id,
s.Name
}),JsonRequestBehavior.AllowGet);
}

Note the JsonRequestBehavior.AllowGet
added to the return. This is the only change.

This is required due to a security issue desciribed here:
http://haacked.com/archive/2009/06/25/json-hijacking.aspx

Adding this may make your site more vulnerable.