ASP.NET Core MVC での連動ドロップダウンリストの実装方法を備忘録として書いておきます。先の記事「連動ドロップダウンリスト (MVC5 版)」をターゲットフレームワーク .NET 8.0 の ASP.NET Core MVC ベースで書き直したものです。
上の画像がそれで、先の記事と同様に、Microsoft の SQL Server サンプルデータベース Northwind の Categories テーブルと Products テーブルを使って、1 つめのドロップダウンリストで分類 (Categories) を選択すると、2 つめのドロップダウンリストにはその分類に属する製品 (Products) が絞り込まれて表示されるようにしています。
Categories テーブルと Products テーブルは以下の通りとなっています。(EDM のダイアグラムを表示したものです)
分類ドロップダウンリストの選択に応じて jQuery ajax を利用してアクションメソッドに要求を出し、選択された分類に該当するProducts テーブルの ProductID と ProductName を JSON 形式の応答として取得し、製品ドロップダウンリストの内容を書き換えるようにしています。
アプリの作り方の概要を以下に書きます。
(1) プロジェクトの作成
Visual Studio 2022 のテンプレートの中から「ASP.NET Core Web アプリ (Model-View-Controller)」を選び ASP.NET Core MVC アプリのプロジェクトを作成します。
この記事の例ではターゲットフレームワークを .NET 8.0 にしています。その他のバージョンではこの記事の内容と多少違いがあるかもしれません。
(2) コンテキストクラスとエンティティクラスの作成
Entity Framework を利用するので、リバースエンジニアリングという手法を使って、SQL Server データベース Northwind からコンテキストクラスとエンティティクラスを生成します。
作成方法は Microsoft のドキュメント「スキャフォールディング (リバース エンジニアリング)」を見てください。各パラメータについては、こちらの記事 Scaffold-DbContext がまとまっていて分かりやすいと思います。
参考に、EF Core パッケージマネージャーコンソール (PMC) ツールを使った場合の Scaffold-DbContext コマンドの例を下に載せておきます。
Scaffold-DbContext -Connection "Data Source=(localdb)\MSSQLLocalDb;Initial Catalog=Northwind;Integrated Security=True" -Provider Microsoft.EntityFrameworkCore.SqlServer -ContextDir Data -OutputDir Models -Tables Products, Categories, Suppliers, Customers, Orders, Employees, Shippers, "Order Details" -DataAnnotations
(3) コンテキストクラスを DI コンテナに追加
上のステップ (2) で作成したコンテキストクラス NorthwindContext を DI コンテナに追加します。Program.cs に以下のコードを追加してください。"NorthwindConnection" は接続文字列名で任意です。
builder.Services.AddDbContext<NorthwindContext>(options =>
options.UseSqlServer(builder.Configuration
.GetConnectionString("NorthwindConnection")));
appsettings.json に接続文字列を追加します。名前は上のコードで設定したもの(この例では "NorthwindConnection")とし、接続文字列本体はリバースエンジニアリングの Scaffold-DbContext コマンドで使ったものと同じにします。ただし、文字列中の \ は \\ にエスケープする必要があるので注意してください。
(4) PruductsController と View の作成
Visual Studio のスキャフォールディング機能を使って、Products テーブルの CRUD (Create / Read / Upadate / Delete) を行う Controller と View 一式を生成します。
アプリを実行してみて CRUD 機能が期待通り動くことを確認します。
(5) アクションメソッドの追加
この記事の一番上の画像の連動ドロップダウンリスト用のアクションメソッドを作成し、上のステップ (4) で作った PruductsController に追加します。
まずベースとなるビューモデルを作成します。コードは以下の通りです。クラス名は任意ですがここでは Sales としています。
using System.ComponentModel.DataAnnotations;
namespace MvcNet8App.Models
{
public class Sales
{
public int Id { get; set; }
[Display(Name = "分類")]
[Required(ErrorMessage = "{0} の選択は必須")]
public int CategoryId { get; set; }
[Display(Name = "製品")]
[Required(ErrorMessage = "{0} の選択は必須")]
public int ProductId { get; set; }
[Display(Name = "コメント")]
[Required(ErrorMessage = "{0} は必須")]
public string? Comment { get; set; }
}
}
連動ドロップダウンリスト用のアクションメソッド CascadingDropDown と、分類ドロップダウンリストの選択に応じて Products テーブルから選択された分類に該当する ProductID と ProductName を取得して JSON 形式で返すアクションメソッド GetProducts を作成し、PruductsController に追加します。
コードは以下の通りです。
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using MvcNet8App.Data;
using MvcNet8App.Models;
namespace MvcNet8App.Controllers
{
public class ProductsController : Controller
{
private readonly NorthwindContext _context;
public ProductsController(NorthwindContext context)
{
_context = context;
}
// 中略(スキャフォールディングで自動生成されたコード)
public IActionResult CascadingDropDown()
{
ViewData["CategoryId"] =
new SelectList(_context.Categories, "CategoryId", "CategoryName");
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult CascadingDropDown(
[Bind("ProductId,CategoryId,Comment")] Sales sales)
{
if (ModelState.IsValid)
{
return RedirectToAction("Index");
}
// 検証結果 NG で再描画する際は以下のように再度 ViewBag
// に SelectList を設定しないと分類ドロップダウンの中身
// が表示されないので注意
ViewData["CategoryId"] =
new SelectList(_context.Categories, "CategoryId", "CategoryName");
return View(sales);
}
// 製品ドロップダウンに表示する ProductID と ProductName を
// JSON 形式で取得するアクションメソッド。引数 id が分類ドロ
// ップダウンで選択された CategoryID
public async Task<IActionResult> GetProducts(int id)
{
var products = _context.Products
.Where(p => p.CategoryId == id)
.Select(p => new
{
ProductId = p.ProductId,
ProductName = p.ProductName
});
return Json(await products.ToListAsync());
}
}
}
上のアクションメソッド GetProducts が返す JSON 文字列は、デフォルトでは Camel Casing される ( {"name":"value"} の "name" の先頭の文字が小文字になる) ので注意してください。(詳しくは先の記事「JsonSerializer の Camel Casing (CORE)」を見てください)
Camel Casing されないようにするには、Program.cs のコードの中に AddControllersWithViews() メソッドが含まれているはずなので、それに以下のように .AddJsonOptions(opttions => ... 以下のコードを追加します。
builder.Services.AddControllersWithViews().AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = null;
});
これをしないで、クライアントスクリプトで対応しても OK です。いずれにしても、対応しないと製品ドロップダウンリストの内容が undefined となってしまいますので注意してください。
(6) View の追加
上のステップ (5) で作成したアクションメソッド CascadingDropDown に対応する View をスキャフォールディング機能を利用して作成します。レイアウトページ _Layout.cshtml を利用しています。コードは以下の通りです。説明はコメントに書きましたので、それを見てください
@model MvcNet8App.Models.Sales
@{
var data = ViewBag.CategoryID;
ViewData["Title"] = "CascadingDropDown";
}
<h1>CascadingDropDown</h1>
<div class="row">
<div class="col-md-4">
<form asp-action="CascadingDropDown">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Id" />
<div class="form-group">
<label asp-for="CategoryId" class="control-label"></label>
@*
スキャフォールディングで生成される input を select に書き換え
*@
<select asp-for="CategoryId" class="form-control"
asp-items="ViewBag.CategoryId">
<option value="">▼分類を選択してください▼</option>
</select>
<span asp-validation-for="CategoryId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ProductId" class="control-label"></label>
@*
スキャフォールディングで生成される input を select に書き換え
asp-for="ProductId" が無いと検証が動かないので注意
*@
<select asp-for="ProductId" class="form-control"></select>
<span asp-validation-for="ProductId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Comment" class="control-label"></label>
<input asp-for="Comment" class="form-control" />
<span asp-validation-for="Comment" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
<script type="text/javascript">
//<![CDATA[
$(function () {
// 分類ドロップダウンの jQuery オブジェクトを取得。
// html コードの id は html ヘルパの Id メソッドで取得
var categoryDDL = $('#@Html.Id("CategoryId")');
var productDDL = $('#@Html.Id("ProductId")');
// 以下は初期画面および検証結果 NG での再描画の際必要。
// 初期画面では categoryDDL.val() は上の option 要素の
// value="" を取得するので if 文の条件は false となる。
// 検証結果 NG で再描画される際は分類ドロップダウンの
// 選択に応じて製品ドロップダウンの内容を書き換える
if (categoryDDL.val() != "") {
productDDL.children().remove();
productDDL.append(
'<option value="">▼製品を選択してください▼</option>');
productDDL.removeAttr('disabled');
// 分類ドロップダウンの選択結果 (CategoryId) を
// jQuery ajax でサーバー側の GetProducts アクション
// メソッドに送信。アクションメソッドは Products テ
// ーブルに SELECT クエリを発行し、CategoryId に属す
// 製品の ProductId, ProductName を取得して JSON 文
// 字列として返す。その JSON 文字列から option 要素
// の文字列を組み立てて製品ドロップダウンに追加する
$.ajax({
url: '/Products/GetProducts/' + categoryDDL.val(),
method: 'get',
}).done(function (data) {
$.each(data, function (key, val) {
productDDL.append('<option value=' +
val.ProductId + '>' + val.ProductName +
'</option>');
});
}).fail(function (jqXHR, textStatus, errorThrown) {
alert('Error getting products!');
});
} else {
productDDL.children().remove();
productDDL.attr('disabled', 'disabled');
}
// 分類ドロップダウンの選択が変更されると change イベント
// が発生 するのでそのリスナで製品ドロップダウンの内容を
// 分類に応じて書き換え。リスナの中身は上と同じコード
categoryDDL.on("change", function () {
if (categoryDDL.val() != "") {
productDDL.children().remove();
productDDL.append(
'<option value="">▼製品を選択してください▼</option>');
productDDL.removeAttr('disabled');
$.ajax({
url: '/Products/GetProducts/' + categoryDDL.val(),
method: 'get',
}).done(function (data) {
$.each(data, function (key, val) {
productDDL.append('<option value=' +
val.ProductId + '>' + val.ProductName +
'</option>');
});
}).fail(function (jqXHR, textStatus, errorThrown) {
alert('Error getting products!');
});
} else {
productDDL.children().remove();
productDDL.attr('disabled', 'disabled');
}
});
});
//]]>
</script>
}