ASP.Net Core+RazorPage+Vue（ラジオボタンとチェックボタンを作ってみる）

**** 2020.5.21 タイトル変えました ****

　先の記事で必死になってRazorPageのバリデーションを作ってみましたがここで方を変えます。
　作ったものも技術的にま使うかもしれませんが、Vueのコンポーネントを組み込んでみて、以下の理由で違う方向性に進むことにしました。
## 現状の問題点
（１）RazorPageのタグヘルパーの影響で、データ型がinputタグのtypeに反映されるがこれが微妙
（２）inputタグのtype属性への対応がブラウザごとにまちまち（すごく便利な日付入力があるブラウザもあれば、何もしないブラウザもある）
（３）整数の数値入力はブラウザでバリデーションを含めてかなり対応できているようだが、少数についてはほとんどできていない。

　本来、我々の仕事はクライアントの要求を実現する事に時間を割くべきであり、この記事に書いている基本的なコントロールなんかは作りやすいように整っているべき（いままでWeb環境に手を出さなかったのはそこが問題だから）。だいぶましになったっかとみて手を出してみたけれど、やっぱり使いにくい。現状ではRazorPageとVue.jsのコンビネーションで使いやすそうな基礎コンポーネントが作れるかなとやってみてます。できたものは自由に使っていただいて結構なので、記事の内容について、既にあるよとか、ソースのバグ、考え方のおかしなところや、ソースの効率、見やすさなどのコメントが貰えればうれしいです。

## 目標・条件
（１）クライアントでのバリエーションはクライアントで行うべき
（２）サーバーバリデーションブラウザ以外の手段で無理やりPOSTされた時のために残しておく
（３）入力用のコンポーネントを充実させることで、基本的にはバリデーションすら不要にしたい
（４）IE11までをサポート対象とする（いっそのこと割り切ってEdge以降にした方がすっきりしますが、まだまだ使われているようなので）

## 今回の課題
　今回は、Vueコンポーネントとして
（１）RadioButtonsコンポーネント
（２）CheckBoxsコンポーネント
の2つを作ってみました。

## 前提
　ASP.NetCore RazorPage+Vue+blumaの環境を利用します。
　以前に書いた「[Vue.jsを利用してみる（１）](https://qiita.com/nosa67/items/49ac036a15a4f7deed8b)」と「[Vue.jsを利用してみる（１）](https://qiita.com/nosa67/items/49ac036a15a4f7deed8b)」を参照しての環境を構築します。

### RadioButtons
　ラジオボタンのまとまりを一つのコンポーネントとして1タグで使えるようにしてみました。
　まず、Vueコンポーネントを作ってみました。Vueコンポーネントについては勉強してください。引っかかった部分だけ説明します。

#### RadioButtonsコンポーネント
```js:RadioButtons.js
//======================================================================
// ラジオボタンタグ
// ラジオボタンをグループで設定し、選択されている一つのデータを返す
// VueRadioButtonsTagHelperと連携して利用する
// 使用方法は「VueRadioButtonsTagHelper」を参照
//======================================================================
Vue.component('radio-buttons', {
    props:
    {
        hiddenid: String,
        hiddenname:String,
        list: Array,
        value: String,
        selectedclass: String,
        notselectedclass: String
    },
    data: function () {
        return {
            selectedValue: this.value,
            groupName: "name_" + new Date().getTime().toString(16) + Math.floor(10000 * Math.random()).toString(5)
        };
    },
    methods: {
        selectAction: function (e) {
            // 同じものを選択した場合は処理しない
            if (this.selectedValue === e.target.getAttribute("value")) return;

            // 選択値を変更する
            this.selectedValue = e.target.getAttribute("value"); 
        },
        isSelected: function (val) {
            return val === this.selectedValue;
        }
    },
    template: '<div>\
                    <input :id=hiddenid :name=hiddenname type="hidden" :value=selectedValue>\
                    <span v-for="item in list" :key="item.value">\
                        <span style="display:inline-block" :class="[isSelected(item.value) ? selectedclass : notselectedclass ]" :value=item.value>\
                            <input type="radio" :name=groupName :checked=isSelected(item.value) v-on:click=selectAction :value=item.value >\
                            <label v-on:click=selectAction :value=item.value>{{ item.key }}</label>\
                        </span>\
                    </span>\
                </div>'
    
});
```

　IE11対応のためにES2015(ES6)が使えずimportできないので、単一ファイルコンポーネントで書けません。あげく、テンプレート文字列も使えないのでシングルクォートでバックラッシュ改行を使う羽目になっています。IE11を捨てるならそちらを使った方がカッコいいですし、見やすくきれいだと思います。

（１）ラジオボタンの作成
　ラジオボタンですので選択リストをList<KeyValuePaire<string,string>>のリストをJSONに変換してlistプロパティに受けています。それをv-forで回してラジオボタンを作成しています。グループをまとめるために、乱数を利用したnameを設定しています。
（２）RazorPageのモデルにバインド
　RazorPageのモデルにバインドさせるために、inputをtype="hide"で作成しidとnameを対象のモデルのメンバになるように「props」の「hiddenid」と「hiddenname」から受け取ります。これは後述のタグヘルパーで設定します。選択された値は「data」の「selectedValue」を利用して、このinputタグのvalueに設定します。
（３）classの連携
　外部からラジオボタンのスタイルを変更できるように「prop」の「selectedclass」と「notselectedclass」で選択時と未選択時のボタンのspanタグのクラスを変更できます。もっと細かい設定がしたいなら、もっといろいろ作ってください。

#### VueRadioButtonsタグヘルパー

```cs:VueRadioButtonsTagHelper.cs
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;

namespace RazorPageVue.VueTagHelpers
{
    /// <summary>
    /// Vueで作ったRadioButtonsコンポーネントをRazorで利用する為のタグヘルパー
    /// 以下のタグ属性を利用できるようにする
    /// asp-for：ラジオボタンの選択値
    /// asp-items: ラジオボタンのリスト（KeyValuePair＜string, string＞のリスト）
    /// selectedclass:選択されているラジオボタンのクラス（ボタンとテキストをくくったspanタグ）
    /// notselectedclass:未選択のラジオボタンのクラス（ボタンとテキストをくくったspanタグ）
    /// [例]
    /// 　<radio-buttons asp-for='@Model.RadioSelected' asp-items='@Model.RadioKeyValueList' selectedclass="has-background-primary"></radio-buttons>
    ///   ModelのRadioSelectedは選択されたラジオボタンの値が設定される
    ///   Model.RadioKeyValueListはラジオボタンのリストをList＜KeyValuePair＜string,string＞＞型で設定する。（keyは表示する文字列、valueは設定される値）
    ///   ただし、RadioSelectedがEnum型の場合、リストを空にしておくと自動的にEnumのリストが設定される。Enumのリストを加工したい場合のみ上記のリストを設定する
    /// </summary>
    [HtmlTargetElement("radio-buttons", TagStructure = TagStructure.NormalOrSelfClosing)]
    public class VueRadioButtonsTagHelper : TagHelper
    {
        /// <summary>
        /// データモデルとの連携の為にタグのidとnameを通常のinputと同じようにするためのヘルパー
        /// </summary>
        private IHtmlHelper _htmlHelper;

        /// <summary>
        /// asp-for属性が入るプロパティ
        /// </summary>
        public ModelExpression AspFor { get; set; }

        /// <summary>
        /// ラジオボタンのリスト(asp-items)
        /// kKeyValuePairのリストで、keyが表示される文字列で、valueが設定される値になる
        /// </summary>
        public List<KeyValuePair<string, string>> AspItems { get; set; }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="htmlHelper"></param>
        public VueRadioButtonsTagHelper(IHtmlHelper htmlHelper)
        {
            _htmlHelper = htmlHelper;
        }

        /// <summary>
        /// htmlヘルパーを利用するために関連付けるビューのコンテキスト
        /// </summary>
        [ViewContext]
        [HtmlAttributeNotBound]
        public ViewContext ViewContext { get; set; }

        /// <summary>
        /// タグの調整処理
        /// </summary>
        /// <param name="context"></param>
        /// <param name="output"></param>
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            // htmlヘルパーをViewコンテキストに結び付ける（これをしないと後の「GenerateIdFromName」メソッドでエラーになる）
            (_htmlHelper as IViewContextAware).Contextualize(ViewContext);

            // RazorPageでPost時にモデルにバインドできるように、Vueのid,name,valueプロパティを設定
            output.Attributes.SetAttribute("hiddenid", _htmlHelper.GenerateIdFromName(AspFor.Name));    
            output.Attributes.SetAttribute("hiddenname", AspFor.Name);
            output.Attributes.SetAttribute("value", AspFor.Model);

            // asp-forがEnum型でリストが未設定の場合はEnumのリストを作成する
            if (AspFor.Metadata.ModelType.IsEnum)
            {
                if (AspItems == null)
                {
                    AspItems = new List<KeyValuePair<string, string>>();

                    foreach (var enumItemName in Enum.GetNames(AspFor.Metadata.ModelType))
                    {
                        AspItems.Add(new KeyValuePair<string, string>(enumItemName, enumItemName));
                    }
                }
            }

            // 選択リストをJSONに変換して設定
            var serializer = new DataContractJsonSerializer(typeof(List<KeyValuePair<string, string>>));
            using (MemoryStream ms = new MemoryStream())
            {
                serializer.WriteObject(ms, AspItems);
                output.Attributes.SetAttribute("v-bind:list", Encoding.UTF8.GetString(ms.ToArray()));
            }

            base.Process(context, output);
        }
    }
}
```

 このタグヘルパーで「radio-buttons」というタグをサポートします。
（１）asp-for
　おなじみのヘルパー用の属性です。
　この属性に設定したモデルのデータから、「id」と「name」を「radio-buttons」タグの「hiddenid」と「hiddenname」に設定しています。これにより、type=hideにしているinputタグからPOST時にモデルにバインドできます。valueにはモデルのデータが入り、初期値となります。idを構成するのにhtmlヘルパを使っているのですが、ここはおまじないのようなものです。ほかにいい方法ないのかな？
（２）asp-items
　ラジオボタンのリストをList&lt;KeyValuePaire&lt;string,string&gt;&gt;形式でモデルのデータとして設定します。keyに表示する内容を、valueには選択時に返す値をペアにしたリストを設定してください。
（３）enum用の対応
　ラジオボタンでEnumを利用する場合のために、小細工しています。
　asp-forでenum型のデータを指定した場合、自動的にenumのリストが構築されるのでasp-itemsを設定する必要がありません。ただし、enumの全ての値を使いたくない場合は、独自のリストを作ってasp-itemsに設定してください。

### CheckButtons

#### CheckButtonsコンポーネント
```js:CheckButtons.js
//======================================================================
// チェックボタンタグ
// チェックボタンをグループで設定し、選択されている一つのデータを返す
// VueCheckButtonsTagHelperと連携して利用する
// 使用方法は「VueCheckButtonsTagHelper」を参照
//======================================================================
Vue.component('check-buttons', {
    props:
    {
        hiddenname: String,
        list: Array,
        value: Array,
        selectedclass: String,
        notselectedclass: String
    },
    data: function () {
        return {
            selectedvalues: this.value,
            groupName: "name_" + new Date().getTime().toString(16) + Math.floor(10000 * Math.random()).toString(5)
        };
    },
    methods: {
        selectAction: function (e) {

            var val = e.target.getAttribute("value");
            var exitIndex = this.selectedvalues.indexOf(val);
            if (exitIndex >= 0) {
                this.selectedvalues.splice(exitIndex, 1);
            }
            else {
                this.selectedvalues.push(val);
            }
        },
        isSelected: function (val) {
            return this.selectedvalues.indexOf(val) >= 0;
        }
    },
    template: '<div>\
                    <input :name=hiddenname v-for="selected in selectedvalues" :key=selected type="hidden" :value=selected>\
                    <span v-for="item in list" :key="item.key">\
                        <span style="display:inline-block" :class="[isSelected(item.value) ? selectedclass : notselectedclass ]" :value=item.value>\
                            <input type="checkbox" name=groupName :checked=isSelected(item.value) v-on:click=selectAction :value=item.value >\
                            <label v-on:click=selectAction :value=item.value>{{ item.key }}</label>\
                        </span>\
                    </span>\
                </div>'

});
```

　基本的にはRadioButtonsに似ています。
　RadioButtonsと異なり複数選択可能なので、値を返すためにtype=hideのinputタグを選択した値の個数分作成しています。idは厳密にはインデックスを伴って作るべきですが、なくても動作するので無視しました（おかしかったら追加するかもしれません）。これにより、asp-forのバインドデータList&lt;string&gt;型の変数に格納されます。

#### VueCheckButtonsタグヘルパー

```cs:VueCheckButtonsTagHelper.cs
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Text;

namespace RazorPageVue.VueTagHelpers
{
    /// <summary>
    /// Vueで作ったCheckButtonsコンポーネントをRazorで利用する為のタグヘルパー
    /// 以下のタグ属性を利用できるようにする
    /// asp-for：チェックボタンの選択値リスト
    /// asp-items: チェックボタンのリスト（KeyValuePair＜string, string＞のリスト）
    /// selectedclass:選択されているチェックボタンのクラス（ボタンとテキストをくくったspanタグ）
    /// notselectedclass:未選択のチェックボタンのクラス（ボタンとテキストをくくったspanタグ）
    /// [例]
    /// 　<check-buttons groupName="group1" asp-for='@Model.CheckSelected' asp-items='@Model.RadioKeyValueList' selectedclass="has-background-primary"></radio-buttons>
    ///   ModelのCheckSelectedは選択されたラジオボタンの値が設定される
    ///   Model.CheckKeyValueListはラジオボタンのリストをList＜KeyValuePair＜string,string＞＞型で設定する。（keyは表示する文字列、valueは設定される値）
    ///   ただし、CheckSelectedがEnum型の場合、リストを空にしておくと自動的にEnumのリストが設定される。Enumのリストを加工したい場合のみ上記のリストを設定する
    /// </summary>
    [HtmlTargetElement("check-buttons", TagStructure = TagStructure.NormalOrSelfClosing)]
    public class VueCheckButtonsTagHelper : TagHelper
    {
        /// <summary>
        /// データモデルとの連携の為にタグのidとnameを通常のinputと同じようにするためのヘルパー
        /// </summary>
        private IHtmlHelper _htmlHelper;

        /// <summary>
        /// asp-for属性が入るプロパティ
        /// </summary>
        public ModelExpression AspFor { get; set; }

        /// <summary>
        /// ラジオボタンのリスト(asp-items)
        /// kKeyValuePairのリストで、keyが表示される文字列で、valueが設定される値になる
        /// </summary>
        public List<KeyValuePair<string, string>> AspItems { get; set; }

        /// <summary>
        /// タグの調整処理
        /// </summary>
        /// <param name="context"></param>
        /// <param name="output"></param>
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            // RazorPageでPost時にモデルにバインドできるように、Vueのid,name,valueプロパティを設定
            output.Attributes.SetAttribute("hiddenname", AspFor.Name);
            
            // asp-forがEnum型でリストが未設定の場合はEnumのリストを作成する
            if (AspFor.Metadata.ModelType.GenericTypeArguments[0].IsEnum)
            {
                if (AspItems == null)
                {
                    AspItems = new List<KeyValuePair<string, string>>();

                    foreach (var enumItemName in Enum.GetNames(AspFor.Metadata.ModelType.GenericTypeArguments[0]))
                    {
                        AspItems.Add(new KeyValuePair<string, string>(enumItemName, ((int)Enum.Parse(AspFor.Metadata.ModelType.GenericTypeArguments[0],enumItemName)).ToString()));
                    }
                }
            }

            // 選択リストをJSONに変換して設定
            var serializer = new DataContractJsonSerializer(typeof(List<KeyValuePair<string, string>>));
            using (MemoryStream ms = new MemoryStream())
            {
                serializer.WriteObject(ms, AspItems);
                output.Attributes.SetAttribute("v-bind:list", Encoding.UTF8.GetString(ms.ToArray()));
            }

            // 選択結果リストを設定
            if (AspFor.Metadata.ModelType.GenericTypeArguments[0].IsEnum)
            {
                // Enumの場合は一旦文字列リストに変換してからJSON形式にして設定する
                var workList = new List<string>();
                foreach (var item in (System.Collections.IEnumerable)AspFor.Model) workList.Add(((int)item).ToString());

                serializer = new DataContractJsonSerializer(typeof(List<string>));
                using (MemoryStream ms = new MemoryStream())
                {
                    serializer.WriteObject(ms, workList);
                    output.Attributes.SetAttribute("v-bind:value", Encoding.UTF8.GetString(ms.ToArray()));
                }
            }
            else
            {
                // Enum以外の場合は文字列リストをJSON形式にして設定する
                serializer = new DataContractJsonSerializer(typeof(List<string>));
                using (MemoryStream ms = new MemoryStream())
                {
                    serializer.WriteObject(ms, AspFor.Model);
                    output.Attributes.SetAttribute("v-bind:value", Encoding.UTF8.GetString(ms.ToArray()));
                }
            }

            base.Process(context, output);
        }
    }
}

```
　これもVueRadioButtonsタグヘルパーと似ています。
　バインドした結果を返す隠しフィールドにidを設定していないので、htmlヘルパーは入っていません。RadioButtonsでもなくてもいいと思います。asp-forでバインドするデータがList&lt;string&gt;なので、そのあたりの処理が異なります。また、Enumの場合の処理が少し複雑になっています。

### テスト用

テスト用のindexhtmlとそのページモデルを付けておきます。
namespaceやフォルダは適当に書き換えて使ってください。

概要的には chtmlの最初で作ったタグヘルパーを有効にし、コンポーネントを取り込みます。
「radio-button」と「check-buttons」のタグを作って、最後にjavascriptでVueを実行すればOKです。


#### index.html.cs

```cs:Index.cshtml.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace RazorPageVue.Pages
{
    /// <summary>
    /// テスト用列挙
    /// </summary>
    public enum SampleEnum
    {
        sample1,
        sample2,
        sample3,
        sampleOther
    };

    public class IndexModel : PageModel
    {
        /// <summary>
        /// ラジオボタンの選択リスト
        /// </summary>
        public List<KeyValuePair<string, string>> RadioValues { get; set; } = new List<KeyValuePair<string, string>>();

        /// <summary>
        /// 結合データ
        /// </summary>
        [BindProperty]
        public InputModel BindData { get; set; } = new InputModel();

        public class InputModel
        {
            // ラジオボタンの選択結果
            public string RadioSelected { get; set; } = "2";

            // Enumを利用したラジオボタンの選択結果
            public SampleEnum EnumRadioSelected { get; set; } = SampleEnum.sample2;

            /// <summary>
            /// 通常のチェックボックスの選択結果
            /// </summary>
            public List<string> CheckSelectedList { get; set; } = new List<string>() { "2" };

            /// <summary>
            /// Enumタイプのチェックボックスの選択家かk
            /// </summary>
            public List<SampleEnum> EnumCheckSelectedList { get; set; } = new List<SampleEnum>() { SampleEnum.sample3 };
        }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public IndexModel()
        {
            setRadiList();  // ラジオボタン用選択リストを設定
        }

        /// <summary>
        /// Get処理
        /// </summary>
        public void OnGet()
        {
        }

        /// <summary>
        /// Post処理
        /// </summary>
        /// <returns></returns>
        public async Task<IActionResult> OnPostAsync()
        {
            return Page();
        }

        /// <summary>
        /// 選択用リストの初期化
        /// </summary>
        void setRadiList()
        {
            RadioValues.Add(new KeyValuePair<string, string>("第1項目", "1"));
            RadioValues.Add(new KeyValuePair<string, string>("第2項目", "2"));
            RadioValues.Add(new KeyValuePair<string, string>("第3項目", "3"));
            RadioValues.Add(new KeyValuePair<string, string>("第4項目", "4"));
            RadioValues.Add(new KeyValuePair<string, string>("最終項目", "5"));
        }
    }
}
```

#### index.html
```cshtml:Index.cshtml
@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}
@addTagHelper *,RazorPageVue

@*利用するVueコンポーネントをここで取り込む*@
<script src="~/compornents/RadioButtons.js" asp-append-version="true"></script>
<script src="~/compornents/CheckButtons.js" asp-append-version="true"></script>

<script>
    // 【テスト用】実施中のエレメントの属性をアラート表示する（多いと大変なので修正が必要）
    function checkValues() {
        var findUl = document.getElementById('components-demo');

        showElementAttrs(findUl);
    }

    function showElementAttrs(parentEl) {


        var findLi = parentEl.children;

        for (var i = 0; i < findLi.length; i++) {
            var attrs = "";
            var attrList = findLi[i].attributes
            for (var j = 0; j < attrList.length; j++) {
                attrs += attrList[j].name + ":" + attrList[j].value + ",";
            }
            alert(findLi[i].tagName + " => " + attrs);

            showElementAttrs(findLi[i]);
        }
    }
</script>

<form method="post">
    <div class="text-center" id="components-demo">
        <h1 class="display-4">Welcome</h1>

        通常のラジオボタン<br />
        <radio-buttons groupName="testRadioGroup" asp-for='@Model.BindData.RadioSelected' asp-items='@Model.RadioValues' selectedclass="has-background-primary"></radio-buttons>
        <br />
        <br />

        Enumのラジオボタン
        <br />
        <radio-buttons groupName="testEnumRadioGroup" asp-for='@Model.BindData.EnumRadioSelected' selectedclass="has-background-primary"></radio-buttons>
        <br />
        <br />

        通常のチェックボックス<br />
        <check-buttons groupName="testCheckGroup" asp-for='@Model.BindData.CheckSelectedList' asp-items='@Model.RadioValues' selectedclass="has-background-primary"></check-buttons>
        <br />
        <br />

        Enumのチェックボックス<br />
        <check-buttons groupName="testCheckGroup" asp-for='@Model.BindData.EnumCheckSelectedList' selectedclass="has-background-primary"></check-buttons>
        <br />
        <br />

        <input type="submit" />
        <button onclick="checkValues(); return false;">確認</button>
    </div>
</form>


<script>
    // Vueコンポーネントを展開させる
    new Vue({ el: '#components-demo' });
</script>
```

### あとがき
　すでに同じようなものがあるかもしれませんが、比較的簡単にラジオボタンとチェックリストが1タグでRazorPageと連携できました。
　次回以降は、各種入力コンポーネントをデータ型毎に作ってみようかと思っています。

追記：よくよく考えると、ラジオボタンやチェックボックスの描画部分は別途イメージを張り付けておく方がいいと思います。今のままでいくと不要なinputタグがあるためモデルにバインドされなくともpost時に不要なデータを送っているはずなので、イメージとかにすればそれがなくなると思います。