今回の課題
今回は、Vueコンポーネントとして一番単純な文字列のinputを拡張したコンポーネントを作成。バリデーションを行う上での各種クラスの関連性を見ていきます。
(1)TagHelperの処理
(2)バリデーション属性の処理
(3)vueコンポーネントのバリデーションに関する処理
以前、「ASP.Net Core でjQuery無しでHTML5のバリデーションを利用する」でいくつか書いていますが、その内容も含みます。その時は属性は「MergeAttributeは上書きできない」と書いたのですが、いろいろとやってみるとバリデーションクラスでtypeを書き換えちゃってます。内部処理によってはできるものとできないものがあるのかもしれません。書き方が悪かったのかもしれません。まだまだ試行錯誤がありそうです。
前提
ASP.NetCore RazorPage+Vue+blumaの環境を利用します。
以前に書いた「Vue.jsを利用してみる(1)」と「Vue.jsを利用してみる(1)」を参照しての環境を構築します。
タグヘルパー
数値や日時などの入力でも共通して使うことを考えて、入力タグの基本クラスを定義します。テキスト入力は、この基本クラスを継承してますが、何も変えていません。
まず基本クラスです。
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
namespace RazorPageVue.VueTagHelpers
{
public class VueInputTagHelper : TagHelper
{
/// <summary>
/// バインド対象のcshtmlのタグの属性名
/// </summary>
protected const string ForAttributeName = "asp-for";
/// <summary>
/// type属性に設定する値(空ならデータ型に合わせて設定される)
/// </summary>
protected string _overrideType = null;
// それぞれのタグを作成するので不要→やっぱり必要設定しないとGeneratorが「type="text"」を設定してしまう
/// <summary>
/// バインド対象取得用のプロパティ。HtmlAttributeNameの引数名のタグ属性名が対象となる
/// </summary>
[HtmlAttributeName(ForAttributeName)]
public ModelExpression For { get; set; }
/// <summary>
/// タグ属性「name」を受け取るプロパティ。(バインド対象が未設定の場合に出力するタグのname属性の値になる)
/// </summary>
public string Name { get; set; }
/// <summary>
/// タグ属性「value」を受け取るプロパティ。(バインド対象が未設定の場合に出力するタグのvalue属性の値になる)
/// </summary>
public string Value { get; set; }
/// <summary>
/// ビューのコンテキスト
/// </summary>
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
/// <summary>
/// バリデーターの処理を実施させる為に設定するテキストボックス用のジェネレータ作成用
/// (コンストラクタのデータインジェクションで設定)
/// </summary>
protected IHtmlGenerator Generator { get; }
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="generator">The <see cref="IHtmlGenerator"/>.</param>
public VueInputTagHelper(IHtmlGenerator generator)
{
Generator = generator;
}
/// <summary>
/// タグヘルパーの実行実装
/// </summary>
/// <param name="context">タグヘルパーコンテキスト</param>
/// <param name="output">タグヘルパー出力</param>
public override void Process(TagHelperContext context, TagHelperOutput output)
{
// 引数のコンテキストと出力がnullならエラー
if (context == null) throw new ArgumentNullException(nameof(context));
if (output == null) throw new ArgumentNullException(nameof(output));
// テキストボックスのタグビルダーを作成し、バリデーションなどの属性で設定されているものをタグに取り込む
// この時、すでに設定されているタグの属性は更新できないので注意
output.MergeAttributes(GenerateTextBox(Value));
}
/// <summary>
/// テキストボックスのタグビルダーを作成
/// </summary>
/// <param name="value">タグのvalueに設定されている値(無ければnull)</param>
/// <returns>標準のinputタグに近いタグビルダー</returns>
private TagBuilder GenerateTextBox(object value)
{
var modelExplorer = For.ModelExplorer;
IDictionary<string, object> htmlAttributes = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
//// typeはforまたはプレフィックスが設定されていて_overrideTypeが設定され手入れば設定する。
//// これを利用して継承クラスからtypeを設定できるようにしている
if (!string.IsNullOrEmpty(_overrideType)) htmlAttributes.Add("type", _overrideType);
// それぞれのタグを作成するので不要→やっぱり必要設定しないとGeneratorが「type="text"」を設定してしまう
// asp-forが設定されているかどうかでnameとvalueの異なるタグビルダーを作成
if (string.IsNullOrEmpty(For.Name) &&
string.IsNullOrEmpty(ViewContext.ViewData.TemplateInfo.HtmlFieldPrefix))
{
// asp-forが未設定の場合、nameやvalueに設定されている値でタグを作成
return Generator.GenerateTextBox(
ViewContext,
modelExplorer,
Name,
value,
null,
htmlAttributes);
}
else
{
// asp-forが設定されている場合、nameやvalueはasp-forの内容に強制される
return Generator.GenerateTextBox(
ViewContext,
modelExplorer,
For.Name,
modelExplorer.Model,
null,
htmlAttributes);
}
}
/// <summary>
/// 入力するデータの型のヒントリストを取得する
/// 基本的に「InputTagHelper」の実装をそのまま利用している
/// </summary>
/// <returns>バインドしているデータ型のヒントリスト</returns>
protected IEnumerable<string> GetInputValueTypeHints()
{
var modelExplorer = For.ModelExplorer;
// テンプレートヒントが有ればそれをリストにして返す
if (!string.IsNullOrEmpty(modelExplorer.Metadata.TemplateHint))
{
yield return modelExplorer.Metadata.TemplateHint;
}
// データタイプ名が有ればそれをそれをリストにして返す
if (!string.IsNullOrEmpty(modelExplorer.Metadata.DataTypeName))
{
yield return modelExplorer.Metadata.DataTypeName;
}
// In most cases, we don't want to search for Nullable<T>. We want to search for T, which should handle
// both T and Nullable<T>. However we special-case bool? to avoid turning an <input/> into a <select/>.
var fieldType = modelExplorer.ModelType;
if (typeof(bool?) != fieldType)
{
fieldType = modelExplorer.Metadata.UnderlyingOrModelType;
}
foreach (var typeName in TemplateRenderer.GetTypeNames(modelExplorer.Metadata, fieldType))
{
yield return typeName;
}
}
}
}
モデルとのバインド
タグの入力とモデルをバインドさせているのは
public ModelExpression For { get; set; }
の部分で、このプロパティーにバインドしているモデルの変数情報が入ってきます。この時、このプロパティの属性[HtmlAttributeName(ForAttributeName)]でcshtlmのタグの属性名を設定できるようです。つまり「asp-for」でなくともよいということになります。ここでは定数「ForAttributeName」に"asp-for"を設定して基本のままにしています。
html作成時の処理
cshtmlからhtmlを作るときは、メソッド「Process」が呼び出され、引数の「output」にhtml作成の情報を構築していくようです。
inputタグのタグビルダー
「GenerateTextBox(Value)」メソッドでinputタグのタグビルダーを作成して返します。このタグビルダーがデータのバインド(nameとvalueを設定)とtype属性の設定をしているようです。ここでは今後の数値や時刻等の入力の為に、メンバ「_overrideType」を設定しておけば任意の「type」になるように小細工しています。(個別にタグ作っていていらないので削除しました)
また、「asp-for」が指定されていない場合は「name」や「value」に設定されている値を利用するようにしています。
cshtmlのタグ内の「name」や「value」はそれぞれ「Name」「Value」に設定されます。変数名が違うのは言語の仕様の問題でhtmlで利用されるケバブスケースの文字列はプロパティーにできないのでキャメルケースに変換されているからです。少しわかりにくいですね。これも「asp-for」みたいに属性指定にした方がわかりやすいと個人的には思うのですが...。
バリデーションの対応
タグビルダーの結果とバリデーションで設定したいタグの属性を以下の部分でマージしているようです。バリデーションについては後で記述します。
output.MergeAttributes(GenerateTextBox(Value));
引数の型のヒント
「GetInputValueTypeHints()」メソッドは引数の型を判定するためのメソッドです。文字列入力では利用していませんが、時刻や数値の場合に利用しますので継承メソッドとして定義しています。まあ、型判定を入れて文字列以外はエラーにするようにしてもいいかもしれません。
文字列用のタグヘルパー
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace RazorPageVue.VueTagHelpers
{
[HtmlTargetElement("vue-text-input", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
public class VueTextInputTagHelper : VueInputTagHelper
{
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="generator">The <see cref="IHtmlGenerator"/>.</param>
public VueTextInputTagHelper(IHtmlGenerator generator) : base(generator)
{
}
}
}
クラス属性の「HtmlTargetElement」でこのタグヘルパーの対象となるcshtml内のタグ名を「vue-text-input」に設定しています。Attributes には基本クラスで設定ている「asp-for」が入るようにしています。このようにテキストの入力クラスのタグヘルパーは処理は一切変更がありません。数値や日時などではいろいろと変わってくる予定です。
バリデーション
文字列長のバリデーションを以下に示します。
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System;
using System.ComponentModel.DataAnnotations;
namespace RazorPageVue.VueValidations
{
[AttributeUsage(AttributeTargets.Property)]
public class StringLengthAttribute : ValidationAttribute, IClientModelValidator
{
/// <summary>
/// 最小長 0 ならチェック対象外
/// </summary>
public int MaxLength { get; set; } = 0;
/// <summary>
/// 最大を超えた場合のエラーメッセージ
/// </summary>
public string OverMaxErrorMessage { get; set; }
/// <summary>
/// 最大長 0 ならチェック対処具合
/// </summary>
public int MinLength { get; set; } = 0;
/// <summary>
/// 最大を超えた場合のエラーメッセージ
/// </summary>
public string UnderMinErrorMessage { get; set; }
/// <summary>
/// バリデーション(サーバーサイド)
/// </summary>
/// <param name="value">値</param>
/// <param name="validationContext">バリデーションコンテキスト</param>
/// <returns></returns>
protected override ValidationResult IsValid(
object value, ValidationContext validationContext)
{
// 入力が空の場合は常に正常(空のチェックは入力必須でおこなう)
if (value == null) return ValidationResult.Success;
// 最小桁数チェック
if ((MinLength > 0) && (value.ToString().Trim().Length < MinLength))
{
return new ValidationResult(GetUnderMinErrorMessage(validationContext.DisplayName));
}
// 最大桁数チェック
if ((MaxLength > 0) && value.ToString().Trim().Length > MaxLength)
{
return new ValidationResult(GetOverMaxErrorMessage(validationContext.DisplayName));
}
return ValidationResult.Success;
}
/// <summary>
/// クライアントでのバリデーション用の操作
/// </summary>
/// <param name="context">クライアントのバリデーションコンテキスト</param>
public void AddValidation(ClientModelValidationContext context)
{
if (context == null) throw new ArgumentNullException(nameof(context));
if (MinLength > 0)
{
// 最小値が設定されている場合以下のタグ属性を設定する
// minlength 最小桁数
// min-length-err-msg バリデーションで設定されたエラーメッセージ
context.Attributes["minlength"] = MinLength.ToString();
if (!string.IsNullOrWhiteSpace(UnderMinErrorMessage)) context.Attributes["minlength-err-msg"] = UnderMinErrorMessage;
}
if (MaxLength > 0)
{
// 最大値が設定されている場合以下のタグ属性を設定する
// maxlength 最大桁数
// max-length-err-msg バリデーションで設定されたエラーメッセージ
context.Attributes["maxlength"] = MaxLength.ToString();
if (!string.IsNullOrWhiteSpace(OverMaxErrorMessage)) context.Attributes["maxlength-err-msg"] = OverMaxErrorMessage;
}
}
/// <summary>
/// 最大多数のサーバーバリデーション時のエラーメッセージ取得
/// </summary>
/// <param name="displayName">表示名称(DisplayNameアトリビュートで変更できる)</param>
/// <returns>必須エラーメッセージ</returns>
string GetOverMaxErrorMessage(string displayName)
{
if (string.IsNullOrEmpty(OverMaxErrorMessage))
{
return displayName + "の値が最大値「" + MaxLength.ToString() + "」を超えています。";
}
else
{
return OverMaxErrorMessage;
}
}
/// <summary>
/// 最小桁数のサーバーバリデーション時のエラーメッセージ取得
/// </summary>
/// <param name="displayName">表示名称(DisplayNameアトリビュートで変更できる)</param>
/// <returns>必須エラーメッセージ</returns>
string GetUnderMinErrorMessage(string displayName)
{
if (string.IsNullOrEmpty(UnderMinErrorMessage))
{
return displayName + "の値が最小値「" + MinLength.ToString() + "」より小さいです。";
}
else
{
return UnderMinErrorMessage;
}
}
}
}
クラスの前に「[AttributeUsage(AttributeTargets.Property)]」と書かれています。これでこのクラスのクラス名から「Attribute」を除いた名前のプロパティ用のアトリビュートができます。バリデーションクラスを継承しているのでバリデーションアトリビュートになります。
4つのプロパティに最小長、最小長エラーメッセージ、最大長、最大長エラーメッセージを設定しています。
「IsValid」メソッドは、サーバーまで来たときに行われるバリデーションで、クライアントでバリデーションエラーになるとここまで来ません(画面を使わずに直接POST処理を実施すれば別ですが)。
「AddValidation」メソッドがクライアントバリデーションのポイントになります。これは「IClientModelValidator」インターフェースの実装で、この処理がタグヘルパーの「output.MergeAttributes」の中で呼ばれているようです。これによって、ページモデルの参照用のプロパティにこのバリデーションを追加すると実施のhtmlの作成時にタグの属性を追加することができます。ここではプロパティーに設定されている最小長、最小長エラーメッセージ、最大長、最大長エラーメッセージをタグの属性に追加しています。URLやEmailのバリデーションをセットした場合はここでtype属性を変更させます。
Vueコンポーネント
Vue.component('vue-text-input', {
props:
{
id: String, // id
name: String, // name
required: String, // 必須属性
requiredErrMsg: String, // 必須エラーメッセージ
maxlength: Number, // 文字列の最大長
maxlengthErrMsg: String, // 最大長エラーメッセージ
minlength: Number, // 文字列の最小長
minlengthErrMsg: String, // 最小長エラーメッセージ
compareId: String, // 同一比較するコンポーネントのID
compareErrMsg: String, // 同一比較エラーメッセージ
typemismatchErrMsg: String, // 型異常(url,email)のエラーメッセージ
value: String // 入力された値
},
data: function (){
return {
compareCompornent: null, // 自信が比較設定している比較対象のコンポーネント
comparedComponents: [], // 自信が比較設定されている対象のコンポーネント
inputText: this.value
};
},
computed:
{
// inputタグのtypeに設定する値
dataType: function () {
if ((type === "text") || (type === "url") || (type === "email") || (type === "password")) {
return type;
}
return "text";
}
},
methods: {
//------------------------------------------------------------
// 入力が変更された場合に各種バリデーションをチェックする
//------------------------------------------------------------
onChange: function () {
// デフォルトのバリデーションエラーが有れば処理終了
if (window.IsDefaultValidationError(this.$el.validity)) {
// ここでカスタムエラーを削除
this.$el.setCustomValidity("");
return;
}
// 入力必須バリデーション処理
if (!window.RequiredValidation(this.$el, this.required, this.requiredErrMsg)) return;
// 入力文字数バリデーション処理
if (!window.StringLengthValidation(this.$el, this.maxlength, this.maxlengthErrMsg, this.minlength, this.minlengthErrMsg)) return;
// 比較バリデーション処理
if (this.compareCompornent) {
if (!window.CompareValidation(this.$el, this.compareCompornent.inputText, this.compareErrMsg )) return;
}
for (var i = 0; i < this.comparedComponents.length; i++) {
this.comparedComponents[i].onChange();
}
// エラーが無いのでカスタムエラーを削除
this.$el.setCustomValidity("");
},
//------------------------------------------------------------
// バリデーションエラーでメッセージが設定されている場合エラーメッセージを変更する
//------------------------------------------------------------
onInvalid: function (e) {
// 入力必須エラーメッセージの変更(変更した場合は処理終了)
if (window.requiredMsgChange(this.$el, this.requiredErrMsg)) return;
// 入力文字数エラーメッセージの変更
if (stringLengthMsgChange(this.$el, this.maxlengthErrMsg, this.minlengthErrMsg)) return;
// 型異常(URL,Email)エラーメッセージの変更
if (typeMismatchMsgChange(this.$el, this.typemismatchErrMsg)) return;
}
},
mounted: function () {
// 比較対象が設定されている場合、比較対象コンポーネントと相互に関連付けを行う
if (this.compareId) {
for (var j = 0; j < this.$root.$children.length; j++) {
var targetItem = this.$root.$children[j];
if (this.compareId === targetItem.$el.id) {
this.compareCompornent = targetItem;
targetItem.comparedComponents.push(this);
}
}
}
// バリデーションを実施させる
this.onChange();
},
template: '<input :id=id :name=name type=dataType() v-model="inputText" \
:required=required \
:maxlength=maxlength \
:minlength=minlength \
@change=onChange @invalid=onInvalid>'
});
Vueコンポーネントの記述です。バリデーション処理は入力の変更された「onChange」イベント処理で行います。カスタムエラー以外のバリデーションエラーがある場合は、カスタムエラーを消して処理を終了しておきます。(カスタムエラーを消すタイミングがここしかない)
以下はエラーの判定処理です。(別途Vue用の共通のjsのファイルとして作っています。)
function IsDefaultValidationError(validity) {
// デフォルトのバリデートアエラーが有ればtrueを返す
if (validity.valueMissing ||
validity.badInput ||
validity.patternMismatch ||
validity.rangeOverflow ||
validity.rangeUnderflow ||
validity.stepMismatch ||
validity.tooLong ||
validity.tooShort ||
validity.typeMismatch ||
validity.badInput) {
return true;
}
else {
return false;
}
}
「onChange」でいろいろとエラーチェックしていますが、文字列長のチェックは
// 入力文字数バリデーション処理
if (!window.StringLengthValidation(this.$el, this.maxlength, this.maxlengthErrMsg, this.minlength, this.minlengthErrMsg)) return;
の部分で、この実装も「IsDefaultValidationError」と同じファイルに以下のように記述しています。
function StringLengthValidation(element, maxLength, maxlengthErrMsg, minLength, minlengthErrMsg) {
// 空ならtrue;(Requiredでチェックする)
if (!element.value) return true;
// 最大長チェック(ほとんどのブラウザで入力できない様になるので不要と思われる)
if (maxLength) {
if (element.value.length > maxLength) {
if (maxlengthErrMsg) {
element.setCustomValidity(maxlengthErrMsg);
}
else {
element.setCustomValidity(maxLength + "文字以下で入力してください。");
}
return false;
}
}
// 最小長チェック(一部のブラウザでチェックしていない模様)
if (minLength) {
if (element.value.length < minLength) {
if (minlengthErrMsg) {
element.setCustomValidity(minlengthErrMsg);
}
else {
element.setCustomValidity(minLength + "文字以上で入力してください。");
}
return false;
}
}
return true;
}
エレメントの内容とバリデーションで設定されたタグの属性を利用指定最大、最小をチェックしています。
上記のバリデーション処理でエラーとなった場合はエラーメッセージも入っていれば対応していますが、ブラウザの標準機能でエラーとなった場合は「onInvalid」イベントでメッセージの変更を行っており、文字列長については
if (stringLengthMsgChange(this.$el, this._props.maxlengthErrMsg, this._props.minlengthErrMsg)) return;
の部分で行っており、この実装は以下のようになっています。
// 文字数チェックのエラーメッセージ変更
function stringLengthMsgChange(element, maxlengthErrMsg, minLengthErrMsg) {
if (element.validity.tooLong) {
if (maxlengthErrMsg) {
element.setCustomValidity(maxlengthErrMsg);
return true;
}
}
else if (element.validity.tooShort) {
if (minlengthErrMsg) {
element.setCustomValidity(minLengthErrMsg);
return true;
}
}
return false;
}
「element.validity.tooLong」が「true」なら最大長のエラー、「element.validity.tooShort」が「true」なら最小長エラーなので、その場合はエラーメッセージをカスタムエラーを設定しています。カスタムエラーが設定されるとブラウザではそのメッセージ優先されます。
ちょっと変わったところでは、他の入力と比較するバリデーションでは、比較先のエレメントに参照しているエレメントリストをコンポーネント作成時に設定しておき、どちらが変更されてもバリデーションを実行するようにしています。
一応、これで単純なVueコンポーネントのタグヘルパーとバリデーションは作れると思います。
最後に
次回は整数・実数の入力コンポーネントを作るのですが、これはChromeやFireFoxでは加算、減算のボタンがついていますが、IEやEdgeではついていないので、Vueコンポーネントを実行時に動的に置き換える方法で、IEやEdgeでもChromeに似せたコンポーネントに置き換得るようにしてみました。
****
時折LGTMしてくださる方や、何か琴線に触れたのかフォローしてくれた方が居られるようですので、こんな技術にも多少興味がある型が居られるようですので、ぼちぼち書いていきます。
****