ASP.NET Core MVC アプリのレコード一覧画面にページング機能を実装し、例えば、下の画像で言うと検索やソートを行ってからページ 2 を表示し、Edit リンクをクリックして編集画面に遷移し、データベースの UPDATE 完了で元の一覧画面にリダイレクトされた際、同じ検索・ソート条件で同じページ 2 が表示されるようにする機能を実装してみました。
ベースとなるアプリは、Microsoft のチュートリアル「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」です。ただし、データベースは Microsoft のサンプルデータベース Northwind の Products テーブルとし、ページャーを先の記事「ASP.NET Core MVC 用ページャー」のように変更しました。
この記事ではターゲットフレームワークは .NET 8.0 としています。
Microsoft のチュートリアル通り、アクションメソッドで当該ページ部分のレコードのみ切り取って PaginatedList<Product> オブジェクトを作成し、それを View に Model として渡すようにします。
PaginatedList<Product> オブジェクトから PageIndex プロパティで現在のページ番号を取得できます。そのページ番号を「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。
加えて、一覧画面にはソート、検索の機能を実装していますので、現在のソート・検索情報も「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。
そうして最初の「一覧画面」のページ・ソート・検索の条件と同じ条件で最後の「一覧画面」を表示すれば要件を満たすことができます。
その手順をもう少し詳しく書くと以下の通りです。
-
一覧画面から編集画面に遷移するには、上の画像にある[Edit]リンクボタンをクリックして編集用アクションメソッドを要求します。その際、pageIndex, sortInfo, filterInfo という名前のクエリ文字列で現在のページ・ソート・検索条件の情報を渡せるように View の既存の a タグヘルパーに設定します。View ではページ番号は Model.PageIndex で取得できます。ソート・検索条件は既存のコードで ViewData で渡されているのでそれを使います。
-
編集用アクションメソッドの引数 pageIndex, sortInfo, filterInfo にクエリ文字列からページ・ソート・検索情報が渡されるので、それらを取得してから ViewBag を使って編集画面用の View に渡します。
-
編集画面の View の form タグ内に隠しフィールドを 3 つ追加し、それに ViewBag で受け取ったページ・ソート・検索情報を保存します。
-
編集画面でユーザーが編集を完了し[Save]ボタンをクリックすると [HttpPost] 属性が付与された方の編集用のアクションメソッドが呼び出されます。その際、隠しフィールドに保存されたページ・ソート・検索情報も一緒に送信されてきます。
-
編集画面のアクションメソッドでデータベースの UPDATE が完了すると一覧画面にリダイレクトされるので、クエリ文字列でページ・ソート・検索情報を渡せるように、RedirectToAction メソッドの第 2 引数に new { pageNumber = pageIndex, ... } というようにパラメータを追加します。
-
一覧画面がリダイレクトにより GET 要求されますが、その際クエリ文字列でページ・ソート・検索情報が指定されるので、指定された条件で一覧画面が表示されます。
以下にサンプルコードを載せておきます。
コンテキストクラス、エンティティクラス
Microsoft のサンプルデータベース Northwind からリバースエンジニアリングで生成したものを利用しました。ここでの話にはほとんど関係ないのですが、参考に自動生成されたエンティティクラス Product のコードを以下に載せておきます。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace MvcNet8App.Models;
[Index("CategoryId", Name = "CategoriesProducts")]
[Index("CategoryId", Name = "CategoryID")]
[Index("ProductName", Name = "ProductName")]
[Index("SupplierId", Name = "SupplierID")]
[Index("SupplierId", Name = "SuppliersProducts")]
public partial class Product
{
[Key]
[Column("ProductID")]
public int ProductId { get; set; }
[StringLength(40)]
public string ProductName { get; set; } = null!;
[Column("SupplierID")]
public int? SupplierId { get; set; }
[Column("CategoryID")]
public int? CategoryId { get; set; }
[StringLength(20)]
public string? QuantityPerUnit { get; set; }
[Column(TypeName = "money")]
public decimal? UnitPrice { get; set; }
public short? UnitsInStock { get; set; }
public short? UnitsOnOrder { get; set; }
public short? ReorderLevel { get; set; }
public bool Discontinued { get; set; }
[ForeignKey("CategoryId")]
[InverseProperty("Products")]
public virtual Category? Category { get; set; }
[InverseProperty("Product")]
public virtual ICollection<OrderDetail> OrderDetails { get; set; } =
new List<OrderDetail>();
[ForeignKey("SupplierId")]
[InverseProperty("Products")]
public virtual Supplier? Supplier { get; set; }
}
PaginatedList.cs
上に紹介した Microsoft のチュートリアルのコードと同じですが、少しコメントを加えて以下にアップしておきます。
このクラスがページングの中核を担うもので、コントローラーが生成した IQueryable<Product> オブジェクトを CreateAsync メソッドで受け取って、Skip, Take メソッドでページに表示する部分のみをデータベースから取得し、PaginatedList<Product> オブジェクトを生成して戻り値として返すようにしています。
using Microsoft.EntityFrameworkCore;
namespace MvcNet8App.Services
{
public class PaginatedList<T> : List<T>
{
// 表示するページのページ番号
public int PageIndex { get; private set; }
// ページ総数
public int TotalPages { get; private set; }
// コンストラクタ。���の CreateAsync メソッドから呼ばれる
public PaginatedList(List<T> items,
int count,
int pageIndex,
int pageSize)
{
PageIndex = pageIndex;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
this.AddRange(items);
}
// 表示するページの前にページがあるか?
public bool HasPreviousPage
{
get
{
return (PageIndex > 1);
}
}
// 表示するページの後にページがあるか?
public bool HasNextPage
{
get
{
return (PageIndex < TotalPages);
}
}
// 下の静的メソッドがコントローラーから呼ばれて戻り値がモデルとして
// ビューに渡される。引数の pageSize は 1 ページに表示するレコード
// 数でコントローラーから渡される
public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source,
int pageIndex,
int pageSize)
{
var count = await source.CountAsync();
var items = await source.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PaginatedList<T>(items, count, pageIndex, pageSize);
}
}
}
Controller / Action Method
ベースは Visual Studio のスキャフォールディング機能を使って自動生成させたものです。その Index, Edit アクションメソッドをコピーして名前をそれぞれ PagedIndex, PagedEdit に変え、それに手を加えてページング機能を実装したものです。下に載せたコードでは PagedIndex, PagedEdit 以外は省略していますので注意してください。
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using MvcNet8App.Data;
using MvcNet8App.Models;
using MvcNet8App.Services;
namespace MvcNet8App.Controllers
{
public class ProductsController : Controller
{
private readonly NorthwindContext _context;
public ProductsController(NorthwindContext context)
{
_context = context;
}
public async Task<IActionResult> PagedIndex(string? sortOrder,
string? currentFilter,
string? searchString,
int? pageNumber)
{
ViewData["CurrentSort"] = sortOrder;
ViewData["NameSortParm"] = String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
ViewData["PriceSortParm"] = sortOrder == "Price" ? "price_desc" : "Price";
if (searchString != null)
{
pageNumber = 1;
}
else
{
searchString = currentFilter;
}
ViewData["CurrentFilter"] = searchString;
// Include(p => p.Category) を使ったのは CategoryName を表示するため
var products = _context.Products
.Include(p => p.Category)
.Select(p => p);
// 検索条件の設定
if (!String.IsNullOrEmpty(searchString))
{
products = products.Where(p => p.ProductName.Contains(searchString));
}
// ソート条件の設定
// 条件は Name 昇順、Name 降順、Price 昇順、Price 降順のどれか一つ
// デフォルトは Name 昇順
switch (sortOrder)
{
case "name_desc":
products = products.OrderByDescending(p => p.ProductName);
break;
case "Price":
products = products.OrderBy(p => p.UnitPrice);
break;
case "price_desc":
products = products.OrderByDescending(p => p.UnitPrice);
break;
default:
products = products.OrderBy(p => p.ProductName);
break;
}
int pageSize = 5; // 1 ページに表示するレコードの数
// 第 1 引数は検索・ソート条件込みの IQueryable<Products>
// それから pageNumber で指定されたページを切り取った
// PaginatedList<Product> を取得して View に渡す
return View(await PaginatedList<Product>
.CreateAsync(products.AsNoTracking(),
pageNumber ?? 1,
pageSize));
}
// 自動生成された Edit メソッドをコピーして以下のように手を加えた
// ・名前を Edit ⇒ PagedEdit に変更
// ・クエリ文字列で渡される現在のページ、ソート、検索の条件を取得
// する引数 pageIndex, sortInfo, filterInfo を追加
// ・それらの情報を View に渡すため ViewBag を追加。View ではそれら
// を隠しフィールドに保存する
public async Task<IActionResult> PagedEdit(int? id,
int? pageIndex,
string? sortInfo,
string? filterInfo)
{
if (id == null)
{
return NotFound();
}
var product = await _context.Products.FindAsync(id);
if (product == null)
{
return NotFound();
}
// View に現在のページ、ソート、検索の条件を渡すための ViewBag
ViewBag.PageIndex = pageIndex ?? 1;
ViewBag.SoreOrder = sortInfo;
ViewBag.CurrentFilter = filterInfo;
ViewData["CategoryId"] = new SelectList(_context.Categories,
"CategoryId",
"CategoryName",
product.CategoryId);
ViewData["SupplierId"] = new SelectList(_context.Suppliers,
"SupplierId",
"CompanyName",
product.SupplierId);
return View(product);
}
// 自動生成された Edit メソッドをコピーして以下のように手を加えた
// ・名前を Edit ⇒ PagedEdit に変更
// ・クエリ文字列で渡される現在のページ、ソート、検索の条件を取得
// する引数 pageIndex, sortInfo, filterInfo を追加
// ・リダイレクトの際クエリ文字列でページ番号を渡せるよう第 2 引数
// に new { pageNumber = pageIndex, sortOrder = sortInfo,
// currentFilter = filterInfo } を追加
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PagedEdit(int id,
[Bind("ProductId,ProductName,SupplierId,CategoryId," +
"QuantityPerUnit,UnitPrice,UnitsInStock,UnitsOnOrder," +
"ReorderLevel,Discontinued")] Product product,
int? pageIndex,
string? sortInfo,
string? filterInfo)
{
if (id != product.ProductId)
{
return NotFound();
}
if (ModelState.IsValid)
{
try
{
_context.Update(product);
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
if (!ProductExists(product.ProductId))
{
return NotFound();
}
else
{
throw;
}
}
// リダイレクトの際クエリ文字列でページ、ソート、検索情報
// を渡せるよう第 2 引数 に new { pageNumber = pageIndex,
// sortOrder = sortInfo, currentFilter = filterInfo } を追加
return RedirectToAction(nameof(PagedIndex),
new { pageNumber = pageIndex,
sortOrder = sortInfo,
currentFilter = filterInfo});
}
ViewData["CategoryId"] = new SelectList(_context.Categories,
"CategoryId",
"CategoryName",
product.CategoryId);
ViewData["SupplierId"] = new SelectList(_context.Suppliers,
"SupplierId",
"CompanyName",
product.SupplierId);
return View(product);
}
}
}
一覧画面用の View
上に書いたように、[Edit]リンクボタン用の a タグヘルパーに asp-route を設定し、クエリ文字列でページ、ソート、検索条件を PagedEdit アクションメソッドに渡せるようにしているところに注目してください。table 要素の下にページャーも実装しています。
@model MvcNet8App.Services.PaginatedList<Product>
@{
ViewData["Title"] = "PagedIndex";
}
<h1>PagedIndex</h1>
<p>
<a asp-action="Create">Create New</a>
</p>
<form asp-action="PagedIndex" method="get">
<div class="form-actions no-color">
<p>
Find by Product Name:
<input type="text" name="SearchString"
value="@ViewData["CurrentFilter"]" />
<input type="submit" value="Search" class="btn btn-primary" /> |
<a asp-action="PagedIndex">Back to Full List</a>
</p>
</div>
</form>
<table class="table">
<thead>
<tr>
<th>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["NameSortParm"]"
asp-route-currentFilter="@ViewData["CurrentFilter"]">
Product Name
</a>
</th>
<th>
Category
</th>
<th>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["PriceSortParm"]"
asp-route-currentFilter="@ViewData["CurrentFilter"]">
Unit Price
</a>
</th>
<th>
Discontinued
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.ProductName)
</td>
<td>
@Html.DisplayFor(modelItem => item.Category!.CategoryName)
</td>
<td>
@Html.DisplayFor(modelItem => item.UnitPrice)
</td>
<td>
@Html.DisplayFor(modelItem => item.Discontinued)
</td>
<td>
@* [Edit]リンクボタン用の a tag helper のパラメータに
asp-route-pageIndex="@Model.PageIndex"
sp-route-sortInfo="@ViewData["CurrentSort"]"
asp-route-filterInfo="@ViewData["CurrentFilter"]"
を追加し、クエリ文字列でページ、ソート、検索条件を Edit アクション
メソッドに渡せるように設定 *@
<a asp-action="PagedEdit"
asp-route-id="@item.ProductId"
asp-route-pageIndex="@Model.PageIndex"
asp-route-sortInfo="@ViewData["CurrentSort"]"
asp-route-filterInfo="@ViewData["CurrentFilter"]">Edit</a> |
<a asp-action="Details" asp-route-id="@item.ProductId">Details</a> |
<a asp-action="Delete" asp-route-id="@item.ProductId">Delete</a>
</td>
</tr>
}
</tbody>
</table>
@{
// buttonCount はページャーの First Prev 1 2 3 ... n Next Last の
// 1 ~ n 部分のボタン数 n。奇数に設定してください
int buttonCount = 7;
}
<span>Page @Model.PageIndex of @Model.TotalPages</span>
<br />
<nav aria-label="Page navigation">
<ul class="pagination">
@if (Model.HasPreviousPage)
{
<li class="page-item">
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="1"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="page-link">
First
</a>
</li>
}
@if (Model.HasPreviousPage)
{
<li class="page-item">
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.PageIndex - 1)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="page-link">
Prev
</a>
</li>
}
@{
int startPage;
int stopPage;
if (Model.TotalPages > buttonCount)
{
if (Model.PageIndex <= buttonCount / 2 + 1)
{
startPage = 1;
stopPage = buttonCount;
}
else if (Model.PageIndex < (Model.TotalPages - buttonCount / 2))
{
startPage = Model.PageIndex - buttonCount / 2;
stopPage = Model.PageIndex + buttonCount / 2;
}
else
{
startPage = Model.TotalPages - buttonCount + 1;
stopPage = Model.TotalPages;
}
}
else
{
startPage = 1;
stopPage = Model.TotalPages;
}
for (int i = startPage; i <= stopPage; i++)
{
if (Model.PageIndex == i)
{
<li class="page-item active">
<span class="page-link">@i</span>
</li>
}
else
{
<li class="page-item">
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@i"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="page-link">
@i
</a>
</li>
}
}
}
@if (Model.HasNextPage)
{
<li class="page-item">
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.PageIndex + 1)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="page-link">
Next
</a>
</li>
}
@if (Model.HasNextPage)
{
<li class="page-item">
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.TotalPages)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="page-link">
Last
</a>
</li>
}
</ul>
</nav>
編集画面用の View
スキャフォールディングで自動生成されたものに、現在のページ・ソート・検索情報を保存する隠しフィールドを追加し、編集を途中で止めて一覧ページに戻る際同じページ・ソート・検索条件の一覧画面を表示するためのクエリ文字列を a タグヘルパーに追加しました。
@model MvcNet8App.Models.Product
@{
ViewData["Title"] = "PagedEdit";
}
<h1>PagedEdit</h1>
<h4>Product</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form asp-action="PagedEdit">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="ProductId" />
<div class="form-group">
<label asp-for="ProductName" class="control-label"></label>
<input asp-for="ProductName" class="form-control" />
<span asp-validation-for="ProductName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SupplierId" class="control-label"></label>
<select asp-for="SupplierId" class="form-control"
asp-items="ViewBag.SupplierId"></select>
<span asp-validation-for="SupplierId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CategoryId" class="control-label"></label>
<select asp-for="CategoryId" class="form-control"
asp-items="ViewBag.CategoryId"></select>
<span asp-validation-for="CategoryId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="QuantityPerUnit" class="control-label"></label>
<input asp-for="QuantityPerUnit" class="form-control" />
<span asp-validation-for="QuantityPerUnit" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UnitPrice" class="control-label"></label>
<input asp-for="UnitPrice" class="form-control" />
<span asp-validation-for="UnitPrice" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UnitsInStock" class="control-label"></label>
<input asp-for="UnitsInStock" class="form-control" />
<span asp-validation-for="UnitsInStock" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="UnitsOnOrder" class="control-label"></label>
<input asp-for="UnitsOnOrder" class="form-control" />
<span asp-validation-for="UnitsOnOrder" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ReorderLevel" class="control-label"></label>
<input asp-for="ReorderLevel" class="form-control" />
<span asp-validation-for="ReorderLevel" class="text-danger"></span>
</div>
<div class="form-group form-check">
<label class="form-check-label">
<input class="form-check-input" asp-for="Discontinued" />
@Html.DisplayNameFor(model => model.Discontinued)
</label>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
@* 隠しフィールドにページ番号・ソート・検索の条件をを保存するため追加 *@
<input type="hidden" name="pageIndex" value="@ViewBag.PageIndex" />
<input type="hidden" name="sortInfo" value="@ViewBag.SoreOrder" />
<input type="hidden" name="filterInfo" value="@ViewBag.CurrentFilter" />
</form>
</div>
</div>
<div>
@* 編集を途中で止めて一覧ページに戻る際、同じページに戻るため
asp-route-pageNumber="@ViewBag.PageIndex"
asp-route-sortOrder="@ViewBag.SoreOrder"
asp-route-currentFilter="@ViewBag.CurrentFilter"
を追加*@
<a asp-action="PagedIndex"
asp-route-pageNumber="@ViewBag.PageIndex"
asp-route-sortOrder="@ViewBag.SoreOrder"
asp-route-currentFilter="@ViewBag.CurrentFilter">Back to List</a>
</div>
@section Scripts {
@{
await Html.RenderPartialAsync("_ValidationScriptsPartial");
}
}