Blazor の EditForm で ReactiveProperty を使う
最近は娯楽が Blazor で遊ぶことになっています!今日やったのは ReactiveProperty<T>
の入力値検証の機能と Blazor の EditForm
の入力値検証を連携させることにチャレンジしました。
とりあえず動くようになったのでメモしておきます。もしこの機能を含んだ ReactiveProperty.Blazor
の NuGet パッケージが欲しいと思った人がいたらコメントか何かで教えてください。
EditForm の入力値検証機能
公式ドキュメントの以下の部分の機能になります。
この検証機能は裏側で EditContext
と ValidationMessageStore
クラスを使ってフィールドごとのエラー情報を設定してやると EditForm
や ValidationMessage
や ValidationSummary
と連携して画面にエラーメッセージなんかを表示してくれます。このとき EditContext
で管理しているエラーの情報は FieldIdentifier
という構造体がキーになっていて、FieldIdentifier
はフィールド(またはプロパティ)を持っているオブジェクトのインスタンスと、フィールド名を持っています。
通常のケースではフィールドを持ってるオブジェクトは EditForm
の Model
プロパティに設定されたオブジェクトで、フィールド名はコントロールに表示したいデータに対応するプロパティ名になります。
以下のようなケースでは exampleModel
が FieldIdentifier
に設定されるオブジェクトのインスタンスで 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>
の検証機能を設定しても画面上に検証エラーメッセージも表示されませんし、検証エラーがあっても EditForm
の OnValidSubmit
イベントが発生するといったように全く連携してくれません。
<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
に通知します。
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();
}
では使ってみましょう。
@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 自体は結構考えて作られてる気配を感じるので非対応なものでも、とりあえず拡張可能そうな場所を探して手を入れると今回みたいになんとかなってしまうところが良くできてるなぁと思いました。
Discussion