💨

Blazor の EditForm で ReactiveProperty を使う

2022/04/20に公開

最近は娯楽が Blazor で遊ぶことになっています!今日やったのは ReactiveProperty<T> の入力値検証の機能と Blazor の EditForm の入力値検証を連携させることにチャレンジしました。
とりあえず動くようになったのでメモしておきます。もしこの機能を含んだ ReactiveProperty.Blazor の NuGet パッケージが欲しいと思った人がいたらコメントか何かで教えてください。

EditForm の入力値検証機能

公式ドキュメントの以下の部分の機能になります。

https://docs.microsoft.com/ja-jp/aspnet/core/blazor/forms-validation?view=aspnetcore-6.0

この検証機能は裏側で EditContextValidationMessageStore クラスを使ってフィールドごとのエラー情報を設定してやると EditFormValidationMessageValidationSummary と連携して画面にエラーメッセージなんかを表示してくれます。このとき EditContext で管理しているエラーの情報は FieldIdentifier という構造体がキーになっていて、FieldIdentifier はフィールド(またはプロパティ)を持っているオブジェクトのインスタンスと、フィールド名を持っています。

通常のケースではフィールドを持ってるオブジェクトは EditFormModel プロパティに設定されたオブジェクトで、フィールド名はコントロールに表示したいデータに対応するプロパティ名になります。

以下のようなケースでは exampleModelFieldIdentifier に設定されるオブジェクトのインスタンスで Name がプロパティ名になります。

<EditForm Model="@exampleModel" OnValidSubmit="@HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <InputText id="name" @bind-Value="exampleModel.Name" />

    <button type="submit">Submit</button>
</EditForm>

ReactiveProperty を使うと...

さっきの例の Name プロパティが ReactiveProperty<string> だとすると、以下のように書くことで一応データは表示されます。編集もできるのですが ReactiveProperty<T> の検証機能を設定しても画面上に検証エラーメッセージも表示されませんし、検証エラーがあっても EditFormOnValidSubmit イベントが発生するといったように全く連携してくれません。

<EditForm Model="@exampleModel" OnValidSubmit="@HandleValidSubmit">
    <DataAnnotationsValidator />
    <ValidationSummary />

    <InputText id="name" @bind-Value="exampleModel.Name.Value" />

    <button type="submit">Submit</button>
</EditForm>

ソースコードを確認したら DataAnnotatoinsValidator コンポーネントの中のロジックが InputText などにバインドされてるプロパティが exampleModel の直接のプロパティであるという前提になっているため、うまく動いていませんでした。

なければ作ろう

ということで作ってみましょう。以下のような ReactiveProperty を使ったクラスを EditForm で使っていい感じに検証エラーメッセージが表示されるようにしたいと思います。

using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.ComponentModel.DataAnnotations;
using System.Reactive.Disposables;

namespace BlazorApp5.ViewModels;

public class IndexViewModel : IDisposable
{
    private CompositeDisposable _disposable = new();

    [Required(ErrorMessage = "名前を入力してください。")]
    public ReactiveProperty<string> Name { get; }

    public ReactiveProperty<string> Age { get; }

    public IndexViewModel()
    {
        Name = new ReactiveProperty<string>(
                "", 
                mode: ReactivePropertyMode.Default | ReactivePropertyMode.IgnoreInitialValidationError)
            .SetValidateAttribute(() => Name)
            .AddTo(_disposable);
        Age = new ReactiveProperty<string>(
            "0",
            mode: ReactivePropertyMode.Default | ReactivePropertyMode.IgnoreInitialValidationError)
            .SetValidateNotifyError(x =>
            {
                if (string.IsNullOrWhiteSpace(x)) return "年齢を入力してください。";
                if (!int.TryParse(x, out var age)) return "数字じゃないよ。";
                if (age < 0) return "年齢は0以上で!";
                return null;
            })
            .AddTo(_disposable);
    }

    public void Dispose() => _disposable.Dispose();
}

EditForm と連携して動くためのコンポーネントを作りましょう。ここで Model にある ReactiveProperty<T> 型のプロパティの検証エラーを ValidationMessageStore を使って EditContext に通知します。

ReactivePropertiesValidator.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Forms;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;

namespace BlazorApp5.Shared;

public class ReactivePropertiesValidator : ComponentBase, IDisposable
{
    private readonly CompositeDisposable _disposables = new();

    // EditForm から渡される EditContext
    [CascadingParameter]
    public EditContext? CurrentEditContext { get; set; }

    // 内部利用のための保管用変数
    private EditContext _originalEditContext = default!;

    private IReadOnlyCollection<IReactiveProperty>? _properties;

    protected override void OnInitialized()
    {
        // CurrentEditContext は必須
        if (CurrentEditContext == null)
            throw new InvalidOperationException("EditContext of CascadingParameter is null.");

        _originalEditContext = CurrentEditContext;
        // model が null なら何もしない
        if (_originalEditContext.Model is not { } model) return;
        var messages = new ValidationMessageStore(_originalEditContext);

        // model の ReactiveProperty<T> 型のプロパティのインスタンスを取得
        _properties = model.GetType()
            .GetProperties()
            .Where(x => x.PropertyType.IsAssignableTo(typeof(IReactiveProperty)))
            .Where(x => x.CanRead)
            .Where(x => x.PropertyType.GenericTypeArguments.Length == 1)
            .Where(x => x.PropertyType.IsAssignableTo(typeof(ReactiveProperty<>).MakeGenericType(x.PropertyType.GenericTypeArguments[0])))
            .Select(x => (IReactiveProperty?)x.GetValue(model))
            .Where(x => x != null)
            .Select(x => x!)
            .ToList();
        foreach (var property in _properties)
        {
            // ReactiveProperty<T> のエラーに変更があったら ValidationMessageStore も変更する
            var identifier = new FieldIdentifier(property, nameof(property.Value));
            property.ObserveErrorChanged
                .Subscribe(x =>
                {
                    messages.Clear(identifier);
                    if (x is not null)
                    {
                        foreach (var message in x.OfType<string>())
                        {
                            messages.Add(identifier, message);
                        }
                    }

                    // 検証エラーに変化があったことを通知
                    _originalEditContext.NotifyValidationStateChanged();
                })
                .AddTo(_disposables);
        }

        // バリデーションを要求された場合は全プロパティを強制的に変更通知してバリデーションを走らせる
        _originalEditContext.OnValidationRequested += ValidateAll;
        _disposables.Add(Disposable.Create(() => _originalEditContext.OnValidationRequested -= ValidateAll));
    }

    private void ValidateAll(object? sender, ValidationRequestedEventArgs e)
    {
        if (_properties is null) return;

        foreach (var property in _properties)
        {
            property.ForceNotify();
        }
    }

    protected override void OnParametersSet()
    {
        // 途中で CascadingParameter を変えるのは非サポート
        if (CurrentEditContext != _originalEditContext)
            throw new InvalidOperationException($"{nameof(ReactivePropertiesValidator)} does not support changing EditContext.");
    }

    public void Dispose() => _disposables.Dispose();
}

では使ってみましょう。

Index.razor
@page "/"
@implements IDisposable
@using BlazorApp5.ViewModels

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

<p>@Message</p>

<EditForm Model="_viewModel" OnValidSubmit="OnValidSubmit" OnInvalidSubmit="OnInvalidSubmit">
    <ReactivePropertiesValidator />
    <ValidationSummary />

    <div>
        <label for="name" class="form-label">Name</label>
        <InputText id="name" @bind-Value="_viewModel.Name.Value" class="form-control" />
        <ValidationMessage For="() => _viewModel.Name.Value" />
    </div>
    <div>
        <label for="age" class="form-label">Age</label>
        <InputText id="age" @bind-Value="_viewModel.Age.Value" class="form-control" />
        <ValidationMessage For="() => _viewModel.Age.Value" />
    </div>

    <button type="submit" class="btn btn-primary">Submit</button>
</EditForm>

@code {
    private readonly IndexViewModel _viewModel = new();
    private string? Message { get; set; }

    private void OnValidSubmit(EditContext editContext)
    {
        Message = "Valid!!";
    }

    private void OnInvalidSubmit(EditContext editContext)
    {
        Message = "Invalid!!";
    }

    public void Dispose() => _viewModel.Dispose();
}

実行すると…

正常時

入力にエラーがあるとき

いい感じに動いてます。

まとめ

Blazor になってもプロパティの変更を監視してコンポーネントの表示をリフレッシュするケースがあるので、使いようによっては ReactiveProperty も使えるケースがあると思います。私自身が、まだ Blazor で ReactiveProperty 使って遊んでないので自信は持てませんが…。

とりあえず、今回は使い始めたら一番に問題になりそうな ReactiveProperty<T>EditForm で使ったときに入力値の検証を連携させる方法を試してみました。Blazor 自体は結構考えて作られてる気配を感じるので非対応なものでも、とりあえず拡張可能そうな場所を探して手を入れると今回みたいになんとかなってしまうところが良くできてるなぁと思いました。

Microsoft (有志)

Discussion