WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

ASP.NET Core MVC ページャー付き一覧画面から編集後同じページに戻る

by WebSurfer 23. June 2024 19:13

ASP.NET Core MVC アプリのレコード一覧画面にページング機能を実装し、例えば、下の画像で言うと検索やソートを行ってからページ 2 を表示し、Edit リンクをクリックして編集画面に遷移し、データベースの UPDATE 完了で元の一覧画面にリダイレクトされた際、同じ検索・ソート条件で同じページ 2 が表示されるようにする機能を実装してみました。

ページャー付き Index

ベースとなるアプリは、Microsoft のチュートリアル「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」です。ただし、データベースは Microsoft のサンプルデータベース Northwind の Products テーブルとし、ページャーを先の記事「ASP.NET Core MVC 用ページャー」のように変更しました。

この記事ではターゲットフレームワークは .NET 8.0 としています。

Microsoft のチュートリアル通り、アクションメソッドで当該ページ部分のレコードのみ切り取って PaginatedList<Product> オブジェクトを作成し、それを View に Model として渡すようにします。

PaginatedList<Product> オブジェクトから PageIndex プロパティで現在のページ番号を取得できます。そのページ番号を「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。

加えて、一覧画面にはソート、検索の機能を実装していますので、現在のソート・検索情報も「一覧画面」⇒「編集画面」⇒「一覧画面」と遷移していく際に渡していきます。

そうして最初の「一覧画面」のページ・ソート・検索の条件と同じ条件で最後の「一覧画面」を表示すれば要件を満たすことができます。

その手順をもう少し詳しく書くと以下の通りです。

  1. 一覧画面から編集画面に遷移するには、上の画像にある[Edit]リンクボタンをクリックして編集用アクションメソッドを要求します。その際、pageIndex, sortInfo, filterInfo という名前のクエリ文字列で現在のページ・ソート・検索条件の情報を渡せるように View の既存の a タグヘルパーに設定します。View ではページ番号は Model.PageIndex で取得できます。ソート・検索条件は既存のコードで ViewData で渡されているのでそれを使います。
  2. 編集用アクションメソッドの引数 pageIndex, sortInfo, filterInfo にクエリ文字列からページ・ソート・検索情報が渡されるので、それらを取得してから ViewBag を使って編集画面用の View に渡します。
  3. 編集画面の View の form タグ内に隠しフィールドを 3 つ追加し、それに ViewBag で受け取ったページ・ソート・検索情報を保存します。
  4. 編集画面でユーザーが編集を完了し[Save]ボタンをクリックすると [HttpPost] 属性が付与された方の編集用のアクションメソッドが呼び出されます。その際、隠しフィールドに保存されたページ・ソート・検索情報も一緒に送信されてきます。
  5. 編集画面のアクションメソッドでデータベースの UPDATE が完了すると一覧画面にリダイレクトされるので、クエリ文字列でページ・ソート・検索情報を渡せるように、RedirectToAction メソッドの第 2 引数に new { pageNumber = pageIndex, ... } というようにパラメータを追加します。
  6. 一覧画面がリダイレクトにより 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");
    }
}

Tags: , , , ,

Paging

ASP.NET Core MVC で三点リーダー表示

by WebSurfer 19. May 2024 13:21

ASP.NET Core MVC で表示するテーブルで css の overflow: hidden と text-overflow: ellipsis を使って文字列の長さ制限するとともに末尾に三点リーダーを表示する例を紹介します。下の画像がその例です。ターゲットフレームワークは .NET 8.0、ブラウザは Chrome、フォントはメイリオ、サイズは 16px です。

文字列の長さ制限、三点リーダー表示

先の記事「文字列の長さ制限、三点リーダー表示」では、ASP.NET Web Forms アプリの GridView の例を紹介しました。

その記事と同様に、description 列の文字列を div 要素に入れて、それに以下の css を適用しています。

<style type="text/css">
    div.style1
    {
        width: 320px;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
</style>

html と css の機能なのでアプリが ASP.NET Web Forms から ASP.NET Core MVC に変わっても関係ないと思われるかもしれませんが、ブラウザに送信される文字の形式が異なります。結果は同じになりますが。

参考までに以下に違いを書いておきます。

先の記事の Web Forms の例では、コードビハインドで C# のコードで設定した文字列は UTF-8 に変換されてブラウザに送信されています。文字列は Literal コントロールの Text プロパティに設定したのでエスケープはされません。(なので、先の記事では < > & という文字は C# のコードでは &lt; &gt; &amp; としています)

一方、この記事の ASP.NET Core MVC の場合は、< > & という文字は ASP.NET がエスケープして &lt; &gt; &amp; という文字列としてブラウザに送信されます。また、日本語や絵文字(たぶん非 ASCII 文字すべて)は数値文字参照 &#xNNNN; 形式(NNNN は 16 進 Unicode)に変換されてブラウザに送信されます。以下の画像を見てください。

html ソース

ちなみに、例えば、🍎 は上の画像では &#x1F34E; に該当します。🍎 を IME パッドで見ると Unicode: U+1F34E となっています。

🍎 のコード

一番上の画像の結果から分かるように、サーバーからブラウザに送信される各文字の形式は関係なく、ブラウザ上に表示された文字列の長さで制限がかかり、css の width: 320px で指定された幅いっぱいに三点リーダを含めて表示されています。

フォントはメイリオ、サイズは 16px ですが、MS Gothic などの等幅フォントを使った場合も、フォントサイズを変えた場合も、ブラウザ上に表示される文字列の長さで制限がかかるのは同じです。

三点リーダーを表示する text-overflow:ellipsis はもともと IE の独自拡張だそうですが、最近は他のブラウザでも取り入れられているようです。Windows 10 の Chrome 125.0.6422.61, Edge 125.0.2535.51, Firefox 126.0, Opera 110.0.5130.23 で試してみましたが、同じ結果が得られました。

参考に、上の画像を表示するのに使った ASP.NET Core MVC アプリのコードを載せておきます。

Model

namespace MvcNet8App2.Models
{
    public class Ellipsis
    {
        public int Id { get; set; }
        public string Description { get; set; } = null!;
    }
}

View

@model IEnumerable<MvcNet8App2.Models.Ellipsis>

@{
    ViewData["Title"] = "Ellipsis";
}

<style type="text/css">
    body {
        font-family: "メイリオ";
        font-size: 16px;
    }

    div.style1 {
        width: 320px;
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
    }
</style>

<h1>Ellipsis</h1>

<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Id)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Description)
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Id)
                </td>
                <td>
                    <div class="style1">
                        @Html.DisplayFor(modelItem => item.Description)
                    </div>
                </td>                
            </tr>
        }
    </tbody>
</table>

Controller / Action Method

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MvcNet8App2.Models;
using System.Diagnostics;
using System.Security.Claims;
using System.Security.Principal;

namespace MvcNet8App2.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly CookieAuthenticationOptions _options;

        public HomeController(ILogger<HomeController> logger,
                              IOptions<CookieAuthenticationOptions> options)
        {
            _logger = logger;
            _options = options.Value;
        }


        // ・・・中略・・・

        public IActionResult Ellipsis()
        {
            var model = new List<Ellipsis>
            {
                new ()
                {
                    Id = 1,
                    Description = "エスケープされた < > & " +
                    "などの文字はどのようになるか?"
                },
                new() {
                    Id = 2,
                    Description = "Proportional Font WWWWWWWWWWW " +
                    "iiiiiiii llllll などは?"
                },
                new ()
                {
                    Id = 3,
                    Description = "サロゲートペア 𠀋 𡈽 𠮟 などは" +
                    "どのようになるか?"
                },
                new() {
                    Id = 4,
                    Description = "絵文字 🍎 🍏 (サロゲートペア) " +
                    "👨‍🌾 (ZWJ で結合) などは?"
                }
            };

            return View(model);
        }

        // ・・・中略・・・
    }
}

Tags: , , , , ,

CORE

Razor クラスライブラリ

by WebSurfer 20. March 2024 15:43

Razor Class Library (RCL) に ASP.NET Core MVC の Controller と View を実装し、それを ASP.NET Core MVC アプリから利用する例を書きます。

Razor クラスライブラリの利用

話の発端は Microsoft Q&A のスレッド「.NET MVCで参照登録したDLL��ビューを使用する方法」でクラスライブラリ中のビューが見つからないという話です。それに対処する方法を検討した時の話を備忘録として残しておくことにしました。

ASP.NET Core MVC の cshtml ファイルは、Microsoft のドキュメント「ASP.NET Core での Razor ファイルのコンパイル」によるとデフォルトでビルド時及びデプロイ時に dll にコンパイルされるそうなので、.NET Framework 版の MVC のようなランタイムコンパイルだからダメということはなさそうです。

ただし、アクションがビューを返すときにビューの検出と呼ばれるプロセスが行われるそうで、Microsoft のドキュメント「ビューの検出ルール」に書いてあるように、既定のフォルダに当該ビューが存在しなければなりません。

Microsoft Q&A の話は、独立したクラスライブラリに MVC の Controller と View を実装した結果、MVC アプリがビューを見つけることができず、InvalidOperationException: The view 'Index' was not found いう例外がスローされたということです。

クラスライブラリではなくて、ASP.NET Core MVC 本体のプロジェクトに Controller, Model, View を実装すればそういう悩みは解消できますが、どうしても独立したライブラリを使わなければならない事情があるなら、Razor Class Library として作るのが良さそうです。

Razor Class Library は Razor Pages だけでなく MVC もサポートしており、設定によって Controller, View, Model を実装して、MVC アプリからそれらを呼び出すことができるそうです。

ということで、Microsoft のドキュメント「ASP.NET Core の Razor クラス ライブラリ プロジェクトを使用した再利用可能 UI の作成」とネットで見つけた記事「Working with Razor Class Libraries in ASP.NET Core」を参考にして作ってみました。

ソリューションの構成

上の画像の RazorClassLib が RCL プロジェクトで、RazorClassLibraryHost が MVC アプリのプロジェクトです。両方とも Visual Studio 2022 のテンプレートで作って、前者には Controllers, Models, Views フォルダを追加し、それらの中に Controller, Model, View クラスを実装しています。後者はテンプレートで作ったまま何も手を加えてません。

MVC アプリの実行結果がこの記事の一番上の画像で、MVC アプリから RCL の Controller / View を呼び出して結果を表示したものです。ビューが見つからないという問題は起こりません。

何も難しいことは無かったのですが、Visual Studio で RCL プロジェクトを作成する際に注意すべき点があって、それは[サポート ページとビュー]にチェックを入れることです。

[サポート ページとビュー]にチェック

上に紹介した Microsoft のドキュメントに "Select Support pages and views if you need to support views. By default, only Razor Pages are supported." と書いてありますが、デフォルト([サポート ページとビュー]にチェック無し)では Razor Pages も MVC もサポートされません。その下に書いてある "The Razor class library (RCL) template defaults to Razor component development by default. The Support pages and views option supports pages and views." が正しいです。

Microsoft のドキュメント「クラス ライブラリで ASP.NET Core API を使用する」の「MVC 拡張機能を含める」のセクションの説明によると以下の設定が必要とのことです。

  1. Microsoft.NET.Sdk.Razor SDK を使用する。
  2. true に設定された AddRazorSupportForMvc MSBuild プロパティ。
  3. 共有フレームワークの <FrameworkReference> 要素。

Visual Studio 2022 の RCL テンプレートで[サポート ページとビュー]にチェックを入れて作成したプロジェクトは上の要件を満たしていて、生成されるプロジェクトファイルの内容は以下のようになります。

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  
</Project>

その他、気が付いた点を以下に書いておきます。

MVC アプリから RCL に参照設定がされていれば、RCL の Controller は、MVC アプリのプロジェクトの Program.cs に含まれる builder.Services.AddControllersWithViews(); で DI コンテナに登録されるようです。なので、参照設定以外は何もしなくても、要求に応じて RCL の Controller が呼び出され応答が返ってきます。

MVC アプリから RCL の Razor Pages を呼び出す場合は、Program.cs に builder.Services.AddRazorPages(); と app.MapRazorPages(); を追加する必要があります。追加しないと見つからないというエラーになります。

MVC アプリに実装されている _Layout.cshtml が自動的に使われます。なので、MVC アプリの wwwroot に実装されている Bootstrap などの CSS や jQuery などの JavaScript も有効になります。

Tags: , , , ,

CORE

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  July 2024  >>
MoTuWeThFrSaSu
24252627282930
1234567
891011121314
15161718192021
22232425262728
2930311234

View posts in large calendar