Razor PagesのWebアプリ開発で入れ子構造の複数フォームからのPOST機能を作成する

yuuchan

はじめに

こんにちは、yuuchanです。
前回投稿時と同様、クラウドサービスとしてAzureを扱うPJで業務を行っていますが、最近AzureのApp Service上で動かすWebアプリ開発を担当するようになりました。
業務でガッツリ使っていくのは初めましてのASP.NET CoreのRazor PagesでのWebアプリ開発。新しいフレームワークを習得していくことは覚えることが多く大変な面もありますが、出来ることが増えていくのは素直に楽しいと感じますね!

さて今回は、Razor PagesでのWebアプリ開発において、1つのページに見た目的に入れ子構造になっている複数フォームを作成する場合に、手こずった点やその解決方法を紹介していこうと思います。

前提

  • 本記事で使用するフレームワークは.NET 8のASP.NET Coreです。
  • Razor Pagesテンプレートを用いてWebアプリを作成します。
  • 今回例として作成するWebアプリでは、フォームからPOSTした値やファイルを最終的にDBや任意のファイルストレージに格納することを想定していますが、当該の処理には触れません。

データモデル

本記事では、例として2つのデータモデルを扱います。
それぞれTest01Test02とし、以下のように作成しました。

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace FormTest.Models
{
    public class Test01
    {
        public int ID { get; set; }
        
        [DisplayName("登録番号")]
        [Required(ErrorMessage = "{0}を入力してください。")]
        public int RegistrationNumber { get; set; }

        [DisplayName("氏名")]
        [Required(ErrorMessage = "{0}を入力してください。")]
        public string Name { get; set; }

        [DisplayName("メールアドレス")]
        [Required(ErrorMessage = "{0}を入力してください。")]
        public string Email { get; set; }
    }
}
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace FormTest.Models
{
    public class Test02
    {
        [DisplayName("必要書類")]
        [Required(ErrorMessage = "{0}を選択してください。")]
        public IFormFile FormFile { get; set; }
    }
}

Test01に対応する項目のみをPOSTするフォームの作成

Razor PagesではViewを記述する「*.cshtml」ファイルとView Modelを記述する「*.cshtml.cs」ファイルの組み合わせて1つのページを作成します。
まずは、単純にTest01に対応する項目をPOSTするフォームを作成します。
以下が例として作成したIndex.cshtmlIndex.cshtml.csのコードです。
何らかの登録情報を更新するためのフォームを想定しており、画面表示時には初期値をフォームに表示するようにしてあります。

@page
@model IndexModel
@{
    ViewData["Title"] = "登録情報更新";
}

<h1>登録情報更新</h1>

<hr />

<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Test01.ID" />

            <div class="form-group">
                <label asp-for="Test01.RegistrationNumber" class="control-label"></label>
                <input asp-for="Test01.RegistrationNumber" class="form-control" />
                <span asp-validation-for="Test01.RegistrationNumber" class="text-danger"></span>
            </div>
            <div class="form-group mt-2">
                <label asp-for="Test01.Name" class="control-label"></label>
                <input asp-for="Test01.Name" class="form-control" />
                <span asp-validation-for="Test01.Name" class="text-danger"></span>
            </div>
            <div class="form-group mt-2">
                <label asp-for="Test01.Email" class="control-label"></label>
                <input asp-for="Test01.Email" class="form-control" />
                <span asp-validation-for="Test01.Email" class="text-danger"></span>
            </div>

            <div class="mt-3">
                <input type="submit" value="更新" class="btn btn-primary" />
            </div>
        </form>

        <p class="result">
            @Model.Result
        </p>
    </div>
</div>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}
using FormTest.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace FormTest.Pages;

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    [BindProperty]
    public Test01 Test01 { get; set; } = default!;

    public string Result { get; private set; } = "";

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    // 画面表示時の処理
    public void OnGet()
    {
        // 初期値を設定(本来はDBから値を取得する)
        Test01 = new Test01
        {
            ID = 1,
            RegistrationNumber = 12345,
            Name = "田中太郎",
            Email = "taro.tanaka@example.com"
        };
    }

    // 「更新」ボタン選択時の処理
    public IActionResult OnPost()
    {
        if (!ModelState.IsValid)
        {
            Result = "ModelStateが不正です。";
            return Page();
        }

        // DB更新等の処理を記述

        return RedirectToPage("Index");
    }
}

それでは実際に動かしてみましょう。
Webアプリを起動すると以下のような画面が表示されます。

既に登録されている値が表示されました。
試しに「登録番号」の値を54321に変更して「更新」ボタンを選択してみます。
サーバ側に変更後の値が送られているかどうかデバッガを使用して確認すると、以下のようになっていました。

RegistrationNumberが変更後の値になっており問題無さそうです。

Test02に対応する項目をPOSTするフォームの追加

ここまでは、特になんの変哲もないただの更新フォームを作成しました。
では、ここにTest02に対応する項目「必要書類」をPOSTするフォームを追加していきます。
以下が、「必要書類」フォームの仕様です。

  • フォームの位置は「登録番号」と「氏名」の間。
  • 「必要書類」をアップロードするフォームは「更新」ボタンとは独立して機能し、「アップロード」ボタンを選択した場合にファイルがアップロードされるようにする。

Test01とは独立したフォームとして扱わなければいけませんが、UI上の位置はTest01の中に存在する必要がある。そのようなつくりにしなければなりません。
実はW3の規定にも記載があるとおり、formタグの入れ子は禁止されています。
では、どのように上記仕様を満たせばいいのでしょうか。
試しに以下のように実装してみます。

@page
@model IndexModel
@{
    ViewData["Title"] = "登録情報更新";
}

<h1>登録情報更新</h1>

<hr />

<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Test01.ID" />

            <div class="form-group">
                <label asp-for="Test01.RegistrationNumber" class="control-label"></label>
                <input asp-for="Test01.RegistrationNumber" class="form-control" />
                <span asp-validation-for="Test01.RegistrationNumber" class="text-danger"></span>
            </div>

            <div class="form-group mt-2">
                <input form="upload-file" type="hidden" asp-for="Test01.ID" />
                <div>
                    <label asp-for="Test02.FormFile" class="form-label"></label>
                    <input form="upload-file" asp-for="Test02.FormFile" class="form-control" type="file" />
                    <span asp-validation-for="Test02.FormFile"></span>
                </div>
                <input form="upload-file" type="submit" value="アップロード" class="btn btn-primary" />
            </div>

            <div class="form-group mt-2">
                <label asp-for="Test01.Name" class="control-label"></label>
                <input asp-for="Test01.Name" class="form-control" />
                <span asp-validation-for="Test01.Name" class="text-danger"></span>
            </div>
            <div class="form-group mt-2">
                <label asp-for="Test01.Email" class="control-label"></label>
                <input asp-for="Test01.Email" class="form-control" />
                <span asp-validation-for="Test01.Email" class="text-danger"></span>
            </div>

            <div class="mt-3">
                <input type="submit" value="更新" class="btn btn-primary" />
            </div>
        </form>

        <form id="upload-file" method="post" enctype="multipart/form-data" asp-page-handler="upload">
        </form>

        <p class="result">
            @Model.Result
        </p>
    </div>
</div>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}
using FormTest.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace FormTest.Pages;

public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    [BindProperty]
    public Test01 Test01 { get; set; } = default!;

    [BindProperty]
    public Test02 Test02 { get; set; } = default!;

    public string Result { get; private set; } = "";

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    // 画面表示時の処理
    public void OnGet()
    {
        // 初期値を設定(本来はDBから値を取得する)
        Test01 = new Test01
        {
            ID = 1,
            RegistrationNumber = 12345,
            Name = "田中太郎",
            Email = "taro.tanaka@example.com"
        };
    }

    // 「更新」ボタン選択時の処理
    public IActionResult OnPost()
    {
        if (!ModelState.IsValid)
        {
            Result = "ModelStateが不正です。";
            return Page();
        }

        // DB更新等の処理を記述

        return RedirectToPage("Index");
    }

    // 「アップロード」ボタン選択時の処理
    public IActionResult OnPostUpload()
    {
        if (!ModelState.IsValid)
        {
            Result = "ModelStateが不正です。";
            return Page();
        }

        // ファイルのチェクや格納処理を記述

        return RedirectToPage("Index");
    }
}

上記コードのポイントは以下のとおりです。

  • 追加したformタグの中にinputタグを書いていない。
  • 元々あったformの中にファイルアップロードの為のinputタグを作成し、所属するforminputタグのform属性で指定している。
  • 追加したformタグ内のasp-page-handler="upload"によって、アップロードボタンが選択された際にOnPostUpload()が実行されるようにしてある。

それでは変更後のWebアプリを動かしてみます。
以下のような画面が表示されました。

「必要書類」が「登録番号」と「氏名」の間に表示されていますね。
では、試しに「登録番号」のみを65432に変更して「更新」ボタンを選択してみます。

ModelStateが不正であるエラーが出てしまいました。
デバッガを使用してModelStateの値を調べてみると、以下のようになっていました。

どうやらFormFileがnullとなっているため、ModelStateがInvalidとなってしまっているようです。
今回選択した「更新」ボタンに紐付くformに「必要書類」のinputタグは所属していないはずですがどうしてでしょうか。
実は、データモデルを作成した際にFormFileフィールドにRequired属性をつけてしまったことが原因です。Required属性をつけたデータモデルのフィールドは、POSTされる項目に含まれていなくとも必ずModelStateの確認に含まれてしまうようです。

上記事象を解消するため、データモデルを以下のように変更します。

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace FormTest.Models
{
    public class Test01
    {
        public int ID { get; set; }
        
        [DisplayName("登録番号")]
        public int RegistrationNumber { get; set; }

        [DisplayName("氏名")]
        public string Name { get; set; }

        [DisplayName("メールアドレス")]
        public string Email { get; set; }
    }
}
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace FormTest.Models
{
    public class Test02
    {
        [DisplayName("必要書類")]
        public IFormFile FormFile { get; set; }
    }
}

この状態で、再度「登録番号」のみを65432に変更して「更新」ボタンを選択してみます。
サーバ側に変更後の値が送られているかどうかデバッガを使用して確認すると、以下のようになっていました。

今回は、ModelStateの不正エラーも出ずに更新処理が完了したようです。

では次に、適当なファイル(test01.txt)を選択して「アップロード」を選択してみます。
同様にデバッガを使用して確認すると、以下のようになっていました。

アップロードしたtest01.txtというファイルが問題なくサーバ側に送られているようです。

ただ、Required属性を外したことによりnullのチェックが行われないため、DisplayFormat(ConvertEmptyStringToNull = false)のような属性をデータモデルのフィールドに設定し、テキストフォームに何も入力せずにPOSTした場合にも空文字がバインドされるような対応をするなど、DBの設定を考慮した上で適切な実装を追加する必要があります。

おわりに

いかがでしたでしょうか。
今回は、「Razor PagesのWebアプリと入れ子構造のform」における、実装方法や問題点とその解消方法を記述しました。
formを入れ子構造にするというちょっと変わった実装を行うにあたり、ModelStateを考慮した変更が必要であることが分かりました。

今後も、ASP.NET Coreを用いた開発の中で得た様々な知見や小ネタを発信できればと思います。

AUTHOR
yuuchan
yuuchan
記事URLをコピーしました