WebSurfer's Home

トップ > Blog 1   |   Login
Filter by APML

Controller で作成された匿名型は View でアクセス不可

by WebSurfer 11. October 2024 15:20

.NET Framework 版の ASP.NET MVC アプリでは、Controller で作成されて View に渡された匿名型のオブジェクトには View 内部ではアクセスできず、 アクセスしようとすると以下の画像のように RuntimeBinderException がスローされるということを書きます。

RuntimeBinderException

理由は、Microsoft のドキュメント「匿名型」に書いてあるように、「匿名型のアクセシビリティ レベルは internal であるため」です。internal 型またはメンバは、同じアセンブリのファイル内でのみしかアクセスできません。

.NET Framework 版の MVC アプリでは、Controller など拡張子が cs のファイルは Visual Studio で単一アセンブリにコンパイルされ、bin フォルダに配置されます。

一方、View (.cshtml) は、デフォルトではランタイムコンパイルとなり、アプリをデプロイした後サーバーで動的にアセンブリにコンパイルされ、サーバーの Temporary ASP.NET Files フォルダに保存されます。

という訳で、Controller と View とは違うアセンブリになるため、Controller で作成された匿名クラスのプロパティは View では見えず、アクセスしようとすると上の画像のように RuntimeBinderException がスローされます。

ただし、ASP.NET Core アプリの場合は、Controller と View はデフォルトで単一アセンブリにコンパイルされるので、上に書いたような問題は起きません。(ASP.NET Core のコンパイルについて、詳しくは Microsoft のドキュメント「ASP.NET Core での Razor ファイルのコンパイル」を見てください)

上の画像を表示した MVC アプリの Controler と View のコードを以下に載せておきます。Visual Studio 2022 のテンプレートを使って作成した .NET Framework 4.8 の MVC5 アプリです。

Controller

using System;
using System.Data;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Net;
using System.Web.Mvc;
using Mvc5App2.Models;

namespace Mvc5App2.Controllers
{
    public class ProductsController : Controller
    {
        private NORTHWINDEntities db = new NORTHWINDEntities();


        public async Task<ActionResult> Test()
        {
            var products = db.Products
                .Select(p => new
                {
                    Id = p.ProductID,
                    Name = p.ProductName,
                    Price = p.UnitPrice
                });

            ViewBag.List = await products.ToListAsync();

            return View();
        }
    }
}

View

@{
    ViewBag.Title = "Test";
}

<h2>Test</h2>

<br />
<table  class="table">
    <tr>
        <th>Id</th>
        <th>Name</th>
        <th>Price</th>
    </tr>
    @foreach (var item in ViewBag.List)
    {
        <tr>
            <td>@item.Id</td>
            <td>@item.Name</td>
            <td>@item.Price</td>
        </tr>
    }
</table>

解決策は、匿名型を使うのは止めて、以下のようなカスタムクラスを定義し、

public class DTO
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal? Price { get; set; }
}

以下のように List<DTO> 型のデータを生成して View に渡すことです。

public async Task<ActionResult> Test()
{
    var products = db.Products
        .Select(p => new DTO  // List<DTO> を生成
        {
            Id = p.ProductID,
            Name = p.ProductName,
            Price = p.UnitPrice
        });

    ViewBag.List = await products.ToListAsync();

    return View();
}

なお、上にも書きましたように、ASP.NET Core アプリの場合は、Controller と View はデフォルトで同じアセンブリにコンパイルされるので、上に書いた問題は起きません。

なので、匿名型を使っても以下の画像の通り期待した結果が得られます。

ASP.NET Core での結果

Tags: , ,

MVC

ASP.NET Core MVC でチャンク形式でダウンロード

by WebSurfer 4. August 2024 19:20

ASP.NET Core MVC のアクションメソッドを使って、ファイルをチャンク形式でエンコーディングしてブラウザにダウンロードする方法を書きます。(Core 版の MVC アプリの話です。.NET Framework 版は先の記事「MVC でチャンク形式でダウンロード」を見てください)

チャンク形式でダウンロード

チャンク形式エンコーディングとは、 HTTP/1.1 で定義されている方式で、送信したいデータを任意のサイズのチャンク(塊)に分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。(HTTP/2 はチャンク方式に対応しておらず、もっと効率的なデータストリーミングの仕組みを提供しているそうです)

メリットは、例えば、作成に時間がかかる大量のデータを動的に作成していて、作成中は全体のサイズが分からないが、部分的にでも作成でき次第送信を始められるというところにあるようです。(一旦全データをバッファして全体のサイズを調べ、Content-Length に設定するということをしなくても済みます)

概略方法を書きますと、(1) HttpResponse オブジェクトを取得、(2) それから Body プロパティを使って出力ストリームを取得、(3) コンテンツをチャンクに分割して WriteAsync メソッドでストリームに書き込む、(4) FlushAsync メソッドでクライアントに送信する、(5) 全チャンクを送信するまで (3) と (4) の操作を繰り返す・・・ということになります。

具体例はこの記事の下に載せたコードを見てください。test.pdf は 34,547 バイトの pdf ファイルで、下のコード例にあるアクションメソッドをブラウザから要求すると、その pdf ファイルのデータを 10,000 バイトずつチャンクに分けて送信するようになっています。

結果はこの記事の一番上の画像を見てください。Fiddler を使って要求・応答をキャプチャしたものです。応答ヘッダの赤線で示した部分を見るとチャンク形式エンコーディングになっていることが分かります。コンテンツの反転表示させた部分 32 37 31 30 に最初に送信されたチャンクのサイズが示されていることが分かります (文字コードは ASCII なので 32 37 31 30 は 2710 ⇒ 10 進数に直すと 10000)。

(注: Fiddler で応答コンテンツを見る際「Response body is encoded, Click to decode.」はクリックしないよう注意してください。クリックするとチャンクはまとめられ、さらに応答ヘッダには Content-Length が追加されて、チャンク形式ではなく普通にダウンロードされたように表示されます)

また、上のコードで送信データの最後を示す長さ 0 のチャン���も送信されています (Fiddler で最後のバイト列が 30 0D 0A 0D 0A となっているのを確認)。もちろん pdf ファイルも Content-Disposition に指定した名前で正しくダウンロードされます。

using Microsoft.AspNetCore.Mvc;

namespace MvcNet8App.Controllers
{
    public class DownloadController : Controller
    {
        // Core では Server.MapPath が使えないことの対応
        private readonly IWebHostEnvironment _hostingEnvironment;

        public DownloadController(IWebHostEnvironment hostingEnvironment)
        {
            _hostingEnvironment = hostingEnvironment;
        }

        // アクションメソッドの戻り値は void または Task にできる
        [HttpGet("/ChunkedDownload")]
        [ResponseCache(Duration = 0, 
                       Location = ResponseCacheLocation.None, 
                       NoStore = true)]
        public async Task ChunkedDownload(CancellationToken token)
        {
            // この例では、アプリケーションルート直下の Files という名前の
            // フォルダの中の test.pdf というファイルをダウンロードする
            string contentRootPath = _hostingEnvironment.ContentRootPath;
            string physicalPath = contentRootPath + "\\" + 
                                  "Files\\" + "test.pdf";

            // チャンクサイズは 10000 とした
            int chunkSize = 10000;
            Byte[] buffer = new Byte[chunkSize];

            using (var stream = new FileStream(physicalPath, FileMode.Open))
            {
                long length = stream.Length;

                // 応答ヘッダに Content-Type と Content-Disposition を含める
                Response.ContentType = "application/pdf";
                Response.Headers.Append("Content-Disposition", 
                                        "attachment;filename=test.pdf");

                // MVC5 の Response.IsClientConnected は使えないので代わりに
                // !token.IsCancellationRequested を使う
                // アクションメソッドの引数に CancellationToken を追加してお
                // けば、フレームワークが HttpContext.RequestAborted から取
                // 得した CancellationToken を引数にバインドしてくれる
                while (length > 0 && !token.IsCancellationRequested)
                {
                    // チャンク形式でダウンロードされていることを確認するため
                    // 入れたコード。コメントアウトを外すとここで 3 秒待つ
                    //await Task.Delay(3000, CancellationToken.None);

                    int lengthRead = await stream.ReadAsync(
                                            buffer.AsMemory(0, chunkSize),
                                            token);

                    // MVC5 の Response.OutputStream は使えない
                    // 同期版のWrite メソッドは AllowSynchronousIO がデフォル
                    // トで false なので使えない
                    await Response.Body.WriteAsync(
                                            buffer.AsMemory(0, lengthRead),
                                            token);

                    // MVC5 の Response.Flush() は使えない
                    await Response.Body.FlushAsync(token);

                    length -= lengthRead;

                }
            }
        }
    }
}

注意点があるので以下に書いておきます。

注 1: IIS を使ってのインプロセスホスティングモデルでホストされる ASP.NET Core Web アプリは、クライアントによる要求の中断を検出してサーバー側の処理をキャンセルすることができます (詳しくは先の記事「要求の中断による処理のキャンセル (CORE)」を見てください)。

しかしながら、上のコードの最初の FlushAsync で応答ヘッダと最初のチャンクがブラウザに送信された時点で、ブラウザの X ボタンは表示されなくなり Esc キーは効かなくなって、それらの操作で処理は中断できなくなります。Windows 10 の Chrome 127.0.6533.89, Edge 127.0.2651.86, Firefox 128.0.3, Opera 112.0.5197.39 ですべて同じになることを確認しました。

ブラウザを閉じた場合、Edge 以外では処理は中断されますが、Edge では処理が続行されてダウンロードが完了してしまいます。理由はクライアントによる要求の中断情報を IIS に送れなくて、サーバー側で CancellationToken がキャンセル状態にならないためと思われますが、詳細は調べ切れておらず不明です。

注 2: 上の注 1 のクライアントによる要求の中断は、プロキシが入ると CancellationToken が IIS に届かなくなり、上のコードでは検出できなくなるので注意してください。(何故で検出できないのか悩んでいたら Fiddler を使っていたというのは内緒です(笑))

注 3: チャンクのバイト列を応答ストリームに書き込むのに同期メソッドの Write は使えません。使うと InvalidOperationException がスローされ、"Synchronous operations are disallowed. Call WriteAsync or set AllowSynchronousIO to true instead." というエラーになります。理由は AllowSynchronousIO が Keatrel を使う場合でも IIS を使う場合でもデフォルトで false に設定されているからだそうです。AllowSynchronousIO を true に設定するのではなく、上のコードのように WriteAsync メソッドを使うのが正解と思います。

注 4: ReadAsync および WriteAsync メソッドで、引数に Byte[], Int32, Int32, CancellationToken を取るオーバーロードを使うと、"より効率的なメモリベースのオーバーロードを呼び出すことをお勧めします" とのことでパフォーマンスルール CA1835 が出るので、それに従って Memory<Byte> / ReadOnlyMemory<Byte>, CancellationToken を引数に取るオーバーロードを使いました。

Tags: , , ,

Upload Download

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

About this blog

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

Calendar

<<  October 2024  >>
MoTuWeThFrSaSu
30123456
78910111213
14151617181920
21222324252627
28293031123
45678910

View posts in large calendar