Show cascading dropdown lists in Blazor
In the previous article we learned to cascading create dropdown lists in ASP.NET Core MVC. You might want the same functionality in Blazor Web Assembly apps also. That's what we are going to do in this article.
Before we delve into the code level details, take a look at the following figure that shows a classic example of cascading dropdown lists -- countries, states, and cities.
As you can see, there are three dropdown lists. The first dropdown list shows a list of countries when the page is loaded in the browser. This dropdown list is populated from server side code. Initially the state and city dropdown lists and the submit button are disabled because we want to ensure that the end user selects all the values before submitting the form.
Upon selecting a country, the state dropdown list loads all the states belonging to selected country and is enabled so that the end user can pick a state.
When the end user selects a state, the city dropdown is filled with all the cities for that state and is enabled (along with the submit button) so that the form can be submitted.
Once the form is submitted we simply display a message with the selected country, state, and city.
Now that you know how the application is going to work, let's create Blazor Web Assembly project and add the necessary pieces.
Notice that we have picked Blazor Web Assembly App Empty project template. This project template creates three projects -- a shared class library project, a server project, a client project. The following figures shows these projects in the Solution Explorer after completion.
Since we want to fetch country, state, and city data from a SQL Server database, add the NuGet package for SQL Server data provider for EF Core. This NuGet package needs to be added to the Server project because our DbContext will be in the Server project.
Then add three tables namely Countries, States, and Cities to a SQL Server database as per the following schema.
Then add the following three entity classes in the Shared project. These classes are added to the Shared project because we need them in the Server as well as the Client project.
public class Country
{
public int CountryId { get; set; }
public string CountryName { get; set; }
}
public class State
{
public int StateId { get; set; }
public int CountryId { get; set; }
public string StateName { get; set; }
}
public class City
{
public int CityId { get; set; }
public int StateId { get; set; }
public string CityName { get; set; }
}
We also need a class called Location that contains the country, state, and city IDs selected by the end user. The Location class is also added to the Shared project and is shown below:
public class Location
{
public string CountryId { get; set; }
public string StateId { get; set; }
public string CityId { get; set; }
}
The Location class has three properties namely CountryId, StateId, and CityId. These are string properties (and not int properties) because they store string value selected in the respective dropdown lists. This will be clear when we actually use the Location class in our code.
Now add a new folder named DataAccess in the Server project and create a DbContext class as shown below:
public class AppDbContext:DbContext
{
public DbSet<Country> Countries { get; set; }
public DbSet<State> States { get; set; }
public DbSet<City> Cities { get; set; }
public AppDbContext(DbContextOptions
<AppDbContext> options):base(options)
{
}
}
Register the AppDbContext class with the DI container so that we can inject it into the controller. This is done in the Program.cs file of the Server project. So, open Program.cs and add the following code :
builder.Services.AddDbContext<AppDbContext>
(o => o.UseSqlServer
(builder.Configuration.GetConnectionString("AppDb")));
The above code assumes that you have stored a database connection string named AppDb in the ConnectionStrings section of appsettings.json (Server project). Make sure to add this connection string as per your SQL Server setup. Also make sure to add some sample data to the Countries, States, and Cities tables before you move ahead.
"ConnectionStrings": {
"AppDb": "data source=.;
initial catalog=Northwind;
integrated security=true;
Encrypt=False"
}
If you remember from our previous example, we used JavaScript code to call controller actions that return desired states and cities. Here in Blazor we won't use JavaScript and controller actions. Instead, we will create minimal APIs and call them from Razor Component.
To create the minimal APIs, open Program.cs and add the following Map*() calls just before app.Run() line.
app.MapGet("/api/GetCountries",
(AppDbContext db) =>
{
List<Country> data = db.Countries.ToList();
return Results.Ok(data);
});
app.MapGet("/api/GetStates/{countryId}",
(int countryId, AppDbContext db) =>
{
List<State> data = db.States.Where
(i => i.CountryId == countryId).ToList();
return Results.Ok(data);
});
app.MapGet("/api/GetCities/{stateId}",
(int stateId, AppDbContext db) =>
{
List<City> data = db.Cities.Where
(i => i.StateId == stateId).ToList();
return Results.Ok(data);
});
app.MapPost("/api/SaveLocation",
(AppDbContext db, Location loc) =>
{
var ctry = db.Countries.Find
(int.Parse(loc.CountryId));
var ste = db.States.Find(int.Parse(loc.StateId));
var cty = db.Cities.Find(int.Parse(loc.CityId));
var msg = $"You Selected :
{ctry.CountryName} --
{ste.StateName} --
{cty.CityName}";
return Results.Ok(msg);
});
There are three MapGet() calls and one MapPost() call. The GetCountries API returns all the countries to the caller. We will be calling this API when our razor component is loaded in the browser. The GetStates API returns all the states for a specific country. Similarly, GetCities API returns all the cities for a particular state. Notice that all these APIs receive AppDbContext via dependency injection and ID parameter (wherever applicable) via route.
The SaveLocation MapPost() call is intended to save the user selection in the database. Although, in our example we simply use it to return a message to the UI confirming the user selection of country, state, and city. This API receives AppDbContext via DI and Location object as a part of the POST request body.
Now that our minimal APIs are ready, we can focus on the client application.
The Client project has Pages folder that contains Index.razor component. All our client markup and code will be added to this razor component. So, open Index.razor in the editor and add the code discussed below.
At the top of the Index razor component inject HttpClient because we want to call the minimal APIs created earlier.
@page "/"
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components
@using CascadingDropdownListsInBlazorDemo
@inject HttpClient httpClient
Then add the following properties in the @code block.
@code{
List<Country> Countries { get; set; }
List<State> States { get; set; }
List<City> Cities { get; set; }
bool DisableStates { get; set; }
bool DisableCities { get; set; }
bool DisableSubmit { get; set; }
Location UserLocation { get; set; }
string Message { get; set; }
}
The Countries, States, and Cities properties store the countries, states, and cities returned from the minimal API calls respectively. The DisableStates, DisableCities, and DisableSubmit properties are used to toggle the enable / disable state of the state and city dropdown lists, and the submit button. These properties are used just for additional validation if user changes a selection multiple times. The UserLocation property stores the country, state, and city selected by the end user. The Message property displays success or error messages on the page.
Now add OnInitialized() method below these property declarations.
protected async override void OnInitialized()
{
UserLocation = new Location();
Countries = await httpClient.GetFromJsonAsync
<List<Country>>("/api/GetCountries");
DisableStates = true;
DisableCities = true;
DisableSubmit = true;
this.StateHasChanged();
}
We get all the countries by calling the GetCountries API. This is done using HttpClient's GetFromJsonAsync() method. We also set DisableStates, DisableCities, and DisableSubmit to true so that initially when the component is loaded the state and city dropdown lists, and the submit button are shown disabled.
Then add OnCountryChange() method that handles the onchange event of the country dropdown list.
public async void OnCountryChange(string value)
{
if (!string.IsNullOrWhiteSpace(value))
{
UserLocation.CountryId = value;
States = await httpClient.GetFromJsonAsync
<List<State>>($"/api/GetStates/{value}");
DisableStates = false;
}
else
{
DisableStates = true;
DisableCities = true;
DisableSubmit = true;
UserLocation.CountryId = "";
UserLocation.StateId = "";
UserLocation.CityId = "";
}
this.StateHasChanged();
}
When a user selects a country, OnCountryChange() will be called and the selected countryID will be supplied to it via the value parameter. For any selection other than "Please select" the value will be a non-empty string and hence we add that check inside the OnCountryChange() handler.
We then grab all the states for the selected country by calling the GetStates API using HttpClient's GetFromJsonAsync() method. The states returned by the API are stored in the States property. The selected country is also stored in the CountryId property of the Location object.
The else part of the code simply adjusts the enable / disable for the dropdown lists and the submit button.
We need similar onchange handlers for the state and city dropdown lists. They are shown below:
public async void OnStateChange(string value)
{
if (!string.IsNullOrWhiteSpace(value))
{
UserLocation.StateId = value;
Cities = await httpClient.GetFromJsonAsync
<List<City>>($"/api/GetCities/{value}");
DisableCities = false;
}
else
{
DisableCities = true;
DisableSubmit = true;
UserLocation.StateId = "";
UserLocation.CityId = "";
}
this.StateHasChanged();
}
public void OnCityChange(string value)
{
if (!string.IsNullOrWhiteSpace(value))
{
UserLocation.CityId = value;
DisableSubmit = false;
}
else
{
DisableSubmit = true;
UserLocation.CityId = "";
}
this.StateHasChanged();
}
In the OnStateChange() handler, we grab cities for the selected state and store them in Cities property. The StateId is also stored in the Location object. Inside the OnCityChange() handler CityId of the Location object is set to the selected city value.
When a user selects country, state, and city, and hits the Submit button OnSubmitClick() handler is called. This is shown below:
public async void OnSubmitClick()
{
var resp = await httpClient.PostAsJsonAsync
($"/api/SaveLocation", UserLocation);
Message = await resp.Content.ReadAsStringAsync();
Message = Message.Trim('"');
this.StateHasChanged();
}
Inside, we call the SaveLocation API using HttpClient's PostAsJsonAsync() method. The message returned from the API is displayed on the screen using the Message property. Notice how the message is read using ReadAsStringAsync() of the HttpResponseMessage object.
This completes the @code block. Now let's add the <EditForm> that houses the dropdown lists and the Submit button. Add the following below the @code block.
<EditForm Model="UserLocation"
OnValidSubmit="OnSubmitClick">
</EditForm>
We set the Model for our EditForm to UserLocation object. We also set OnValidSubmit to OnSubmitClick so that upon submitting the form OnSubmitClick will be invoked. The three dropdown lists and the submit button will be placed inside the <EditForm> and </EditForm>. Let's discuss them one by one.
The country dropdown list is shown below:
<h2>Select a country :</h2>
<InputSelect id="country"
ValueExpression="@(() =>
UserLocation.CountryId)"
Value="@UserLocation.CountryId"
ValueChanged=
"@(async (string value)
=> OnCountryChange(value))">
<option value="">Please select</option>
@if (Countries != null)
{
foreach (var c in Countries)
{
<option value="@c.CountryId">
@c.CountryName</option>
}
}
</InputSelect>
Notice this markup carefully. We use Blazor's InputSelect component to display a dropdown list. Typically we use @bind-Value to use Blazor's two way data binding. Here we can't do that because we want to handle onchange event of the dropdown ourselves. So, three properties of the InputSelect component are set -- Value, ValueExpression, and ValueChanged. As you can see, the first two properties get their value from UserLocation.CountryId property. The third property points to the OnCountryChange() handler we wrote earlier in the @code block.
Then we iterate through the Countries list and add option elements in the dropdown list. There is also default "Please select" option element.
On the same lines we need to add state dropdown list.
<h2>Select a state :</h2>
<InputSelect id="state"
disabled="@DisableStates"
ValueExpression=
"@(() => UserLocation.StateId)"
Value="@UserLocation.StateId"
ValueChanged="@(async (string value)
=> OnStateChange(value))">
<option value="">Please select</option>
@if (States != null)
{
foreach (var s in States)
{
<option value="@s.StateId">
@s.StateName</option>
}
}
</InputSelect>
This code should look familiar to you because it is quite similar to the previous InputSelect element. Here we use StateId property and OnStateChange() handler. Moreover, disabled property of InputSelect is also set to the value of DisableStates property.
And now we add city dropdown list as shown below:
<h2>Select a city :</h2>
<InputSelect id="city"
disabled="@DisableCities"
ValueExpression="@(() =>
UserLocation.CityId)"
Value="@UserLocation.CityId"
ValueChanged="@((string value) =>
OnCityChange(value))">
<option value="">Please select</option>
@if (Cities != null)
{
foreach (var c in Cities)
{
<option value="@c.CityId">
@c.CityName</option>
}
}
</InputSelect>
Finally, add the submit button as follows:
<button type="submit"
disabled="@DisableSubmit">
Submit</button>
This completes the Index razor component. Run the application and check whether cascading dropdown lists work as expected. Select a country, state, and city and hit the submit button. The following figure shows a sample run of the application.
That's it for now! Keep coding!!