03 February 2010 ASP.NET MVC, Cascading, DropDown Robert Muehsig

image Wenn zwei oder mehrere Eingabefelder, wie z.B. Dropdowns, durch eine bestimmte Auswahl logisch miteinander verknüpfen will, braucht man einen kleinen Mechanismus. Ich habe das ganze mit Javascript, AJAX und ASP.NET MVC gelöst und stelle die recht simple Lösung vor.

 

Cascading? Ein Bild sagt mehr als tausend Worte

imageDie drei Selectboxen stehen in Beziehung zueinander. Erst wenn man die Automarke ausgewählt hat, kann man das Modell auswählen und erst am Ende die Farbe -> Logisch.

Zu meinem BeispieL

image Nicht ganz so hübsch aussehen, aber ähnliches Prinzip. Erst Landauswählen, dann Bundesland, dann Stadt und am Ende die Straße.

 

Diese Struktur habe ich so auch in meinem Models Ordner, wobei Straßen nur simple Strings sind und ich den deshalb keine eigene Klasse spendiert habe.

image 

Das LocationRepository erzeugt mir ein paar Dummydaten:

    public class LocationRepository
    {
        public static IList<Country> GetCountries()
        {
            List<Country> countries = new List<Country>();

            for (int i = 0; i < 5; i++)
            {
                Country country = new Country();
                country.Name = "Country " + i.ToString();
                countries.Add(country);
            }

            return countries;
        }

        public static IList<FederalStates> GetFederalStates(string countryName)
        {
            List<FederalStates> states = new List<FederalStates>();

            for (int i = 0; i < 5; i++)
            {
                FederalStates state = new FederalStates();
                state.Name = countryName + " - FederalState " + i.ToString();
                states.Add(state);
            }

            return states;
        }

        public static IList<City> GetCities(string federalStateName)
        {
            List<City> cities = new List<City>();

            for (int i = 0; i < 5; i++)
            {
                City city = new City();
                city.Name = federalStateName + " - City " + i.ToString();
                cities.Add(city);
            }

            return cities;
        }

        public static IList<string> GetStreets(string cityName)
        {
            List<string> streets = new List<string>();

            for (int i = 0; i < 5; i++)
            {
                string street = cityName + " - Street " + i.ToString();
                streets.Add(street);
            }

            return streets;
        }
    }

Prinzip ist immer: Ich geb den Namen des höheren Elementes rein und die kleinen Helper bauen mir die entsprechenden Elemente.

Das HomeViewModel

    public class HomeViewModel
    {
        public IList<SelectListItem> Countries { get; set; }
        public IList<SelectListItem> FederalStates { get; set; }
        public IList<SelectListItem> Cities { get; set; }
        public IList<SelectListItem> Streets { get; set; }

        public HomeViewModel()
        {
            this.Countries = new List<SelectListItem>();
            this.Countries.Add(new SelectListItem() { Text = "Please choose...", Value = ""});
            this.FederalStates = new List<SelectListItem>();
            this.FederalStates.Add(new SelectListItem() { Text = "Please choose...", Value = "" });
            this.Cities = new List<SelectListItem>();
            this.Cities.Add(new SelectListItem() { Text = "Please choose...", Value = "" });
            this.Streets = new List<SelectListItem>();
            this.Streets.Add(new SelectListItem() { Text = "Please choose...", Value = "" });
        }
    }

Hier speichern wir unsere 4 Listen und noch einen Default Value.

Der Controller

    [HandleError]
    public class HomeController : Controller
    {
        private HomeViewModel GetHomeViewModel(string country, string federalState, string city, string street)
        {
            HomeViewModel model = new HomeViewModel();

            IList<Country> countries = LocationRepository.GetCountries();
            foreach (Country countryItem in countries)
            {
                SelectListItem item = new SelectListItem();
                item.Text = countryItem.Name;
                item.Value = countryItem.Name;
                model.Countries.Add(item);
            }

            if(string.Empty != country)
            {
                IList<FederalStates> fedStates = LocationRepository.GetFederalStates(country);
                foreach (FederalStates fedItem in fedStates)
                {
                    SelectListItem item = new SelectListItem();
                    item.Text = fedItem.Name;
                    item.Value = fedItem.Name;
                    model.FederalStates.Add(item);
                }
            }

            if(string.Empty != federalState)
            {
                IList<City> cities = LocationRepository.GetCities(federalState);
                foreach (City cityItem in cities)
                {
                    SelectListItem item = new SelectListItem();
                    item.Text = cityItem.Name;
                    item.Value = cityItem.Name;
                    model.Cities.Add(item);
                }
            }

            if(string.Empty != city)
            {
                IList<string> streets = LocationRepository.GetStreets(city);
                foreach (string streetItem in streets)
                {
                    SelectListItem item = new SelectListItem();
                    item.Text = streetItem;
                    item.Value = streetItem;
                    model.Streets.Add(item);
                }
            }

            return model;
        }

        public ActionResult Index()
        {
            HomeViewModel model = this.GetHomeViewModel(string.Empty, string.Empty,string.Empty,string.Empty);
            return View(model);
        }

        [AcceptVerbs(HttpVerbs.Post)]
        public ActionResult Index(string country, string federalState, string city, string street)
        {
            ViewData.ModelState.AddModelError("Message", "Uppps...!");
            HomeViewModel model = this.GetHomeViewModel(country, federalState, city, street);
            return View(model);
        }

        public JsonResult GetFederalStatesJson(string country)
        {
            IList<FederalStates> fedStates = LocationRepository.GetFederalStates(country);
            return Json(fedStates);
        }

        public JsonResult GetCitiesJson(string federalState)
        {
            IList<City> cities = LocationRepository.GetCities(federalState);
            return Json(cities);
        }

        public JsonResult GetStreetsJson(string cityName)
        {
            IList<string> streets = LocationRepository.GetStreets(cityName);
            return Json(streets);
        }
    }

Die 3 Json Actions sprechen mit dem Repository und geben mir die gewünschten Elemente wieder. Dann gibt es noch zwei Index-Actions. Einmal für POST und einmal für einen GET Aufruf.

  • Bei GET wird eine leere Liste zurückgegeben. Wir holen uns das Viewmodel und geben nur die erste Ebene, in meinem Fall die Länder, zurück.
  • Bei POST könnte ja während der Bearbeitung ein Fehler auftreten. Daher schreibe ich dort eine Fehlermeldung in den ModelState. Jetzt übergebe ich alle ausgewählten Daten meiner kleinen "GetHomeViewModel" Methode und lade mir die Daten die ich für den Anzeigen des Views brauche. Vorteil: Die Auswahl die der Nutzer getroffen hat geht nicht verloren.

Das war es eigentlich auch schon im Controller. Natürlich könnte man das alles noch schöner oder generischer bauen, das soll aber auch nur zur Inspiration dienen ;)

Jetzt der View

Das Formular, wobei unser View mit dem HomeViewModel typsiert ist:

    <% using(Html.BeginForm()) { %>
    <div>
        <fieldset>
            <legend>Country/FederalState/City/Street Selection</legend>
            <p>
                <%=Html.ValidationMessage("Message") %>
            </p>
            <p>
                <lable>Country</lable>
                <%=Html.DropDownList("Country",Model.Countries, new { id = "CountrySelection", onchange="changeCountry()" }) %> 
            </p>
            <p>
                <lable>Federal States</lable>
                <%=Html.DropDownList("FederalState", Model.FederalStates, new { id = "FedStateSelection", onchange = "changeFederalState()" })%> 
            </p>
            <p>
                <lable>City</lable>
                <%=Html.DropDownList("City", Model.Cities, new { id = "CitySelection", onchange = "changeCity()" })%> 
            </p>
            <p>
                <lable>Street</lable>
                <%=Html.DropDownList("Street", Model.Streets, new { id = "StreetSelection" })%> 
            </p>
            <button type="submit">Submit!</button>
        </fieldset>
    </div>
    <%} %>

Wir registrieren bei jeden Dropdown, bis auf die letzte Ebene, JavascriptEventhandler und geben unser ViewModel als Value jeweils mit rien.

Das Javascript

<script type="text/javascript">

        function resetSelection(element) {
            element.empty();
            element.append("<option value=''>Please choose...</option>");
        }
        

        function changeCountry() {
            resetSelection($("#FedStateSelection"));
            resetSelection($("#CitySelection"));
            resetSelection($("#StreetSelection"));

            if ($("#CountrySelection").val() != "") {
                var url = "<%=Url.Action("GetFederalStatesJson") %>?country=" + $("#CountrySelection").val();
                $.getJSON(url, null, function(data) {
                    $.each(data, function(index, optionData) {
                        $("#FedStateSelection").append("<option value='"
                         + optionData.Name
                         + "'>" + optionData.Name
                         + "</option>");
                    });
                });
            }
        }

        function changeFederalState() {
            resetSelection($("#CitySelection"));
            resetSelection($("#StreetSelection"));

            if ($("#FedStateSelection").val() != "") {
                var url = "<%=Url.Action("GetCitiesJson") %>?federalState=" + $("#FedStateSelection").val();
                $.getJSON(url, null, function(data) {
                    $.each(data, function(index, optionData) {
                        $("#CitySelection").append("<option value='"
                         + optionData.Name
                         + "'>" + optionData.Name
                         + "</option>");
                    });
                });
            }
            
        }

        function changeCity() {
            resetSelection($("#StreetSelection"));

            if ($("#CitySelection").val() != "") {
                var url = "<%=Url.Action("GetStreetsJson") %>?cityName=" + $("#CitySelection").val();
                $.getJSON(url, null, function(data) {
                    $.each(data, function(index, optionData) {
                        $("#StreetSelection").append("<option value='"
                         + optionData
                         + "'>" + optionData
                         + "</option>");
                    });
                });
            }
        }
    
    </script>

Auch dies könnte sicherlich nocht etwas schöner gestaltet werden, funktioniert aber erstmal und war ein Prototyp. Bei jedem changeXXX löschen wir die Daten der darunterliegenden Felder, da diese ja in Beziehung stehen und laden die Daten des direkten Kindelementes.

Auch hier habe ich für die URLs die URL Helper genommen. Näheres dazu in diesem Blogpost.

Im Fehlerfall

Nun hat der User seine Auswahl getroffen und drückt auf "Submit". Wir übertragen die ausgewählten Daten und laden diese im Backend neu und zeigen wieder den View an. Damit bleiben seine gewählten Daten erhalten und der User freut sich:

image

*Submit* *Irgendwas läuft schief*

image

:)

Ich würde es jetzt nicht unbedingt als "Control" bezeichnen, da es noch zu viel "Detailimplementierung" benötigt, aber wer es braucht: Es funktioniert recht zuverlässig :)

[ Download Democode ]


Written by Robert Muehsig

Software Developer - from Dresden, Germany, now living & working in Switzerland. Microsoft MVP & Web Geek.
Other Projects: KnowYourStack.com | ExpensiveMeeting | EinKofferVollerReisen.de

If you like the content and want to support me you could buy me a beer or a coffee via Litecoin or Bitcoin - thanks for reading!