BlazorでExcel ライクなグリッドJSライブラリ「Handsontable」を使ってみる

# はじめに
これは、[Blazor Advent Calendar 2022](https://qiita.com/advent-calendar/2022/blazor)の16日目の記事となります。

サーバーリプレース作業に伴い、Classic ASPやWCF(Windows Communication Foundation)で作成されたアプリケーションをBlazorとASP.NET Coreを使用して作り直そうと思っています。
Blazorを使用する上でデータグリッドコンポーネントが使用できるのかが気になりました。
UIフレームワークを見ていると[表示のみや簡易的に編集可能なテーブルコンポーネント](https://antblazor.com/en-US/components/table#components-table-demo-edit-cell)は見つかりますが、高機能で編集可能データグリッドは[GrapeCity inc](https://www.grapecity.co.jp/developer/componentone/blazor)や[Infragistics](https://jp.infragistics.com/products/ignite-ui-blazor)の有料のものばかりです。

# Handsontableとは
Handsontableは、ExcelライクなグリッドJavaScriptライブラリーとなります。
また、jQuery などの他のライブラリには依存していません。

最新バージョンは有料ですが、Handsontable 6.2.2以前のバージョンならMITライセンスが適用されます。
自分が2018年に「Handsontable Advent Calendar 2018」を主催しました。

https://qiita.com/advent-calendar/2018/handsontable

個人的には有料でもいいなら、[GrapeCity inc](https://www.grapecity.co.jp/developer/componentone/blazor)や[Infragistics](https://jp.infragistics.com/products/ignite-ui-blazor)のものを使用した方が、拠点が日本にありサポートも充実している上にデータグリッド以外のコンポーネントも含まれるたりするので便利かなと思ってたりします。

# Blazorでの使用方法
## Handsontable 6.2.2
下記サイトからZIPファイルをダウンロードしてください。
https://github.com/handsontable/handsontable/archive/refs/tags/6.2.2.zip
展開してdistフォルダにあるファイルを使用します。
なお、full版は pikaday および numbro を内蔵しています。

### CDN使用
インターネットを使える環境であればCDNを使用するといいでしょう。
CDNを使用する場合、後述する「Blazorプロジェクトに配置」のところを`Libs`側から`https`側に書き換えてください。

```html:_Host.cshtml
    <link href="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.css" rel="stylesheet" media="screen">

    <script src="https://cdn.jsdelivr.net/npm/handsontable@6.2.2/dist/handsontable.full.min.js"></script>
```

## Blazorプロジェクトに配置
今回はBlazor Serverでの使用方法を例にします。
※これから説明する方法は各自で変更可能です。

1. wwwrootフォルダ配下にlibsフォルダを作成し、その配下にcssとscriptsフォルダを作成します。
2. cssフォルダに「handsontable.full.min.css」とscriptsフォルダに「handsontable.full.min.js」を配置します。
3. cssフォルダに「scripts.css」とscriptsフォルダに「scripts.js」を新規作成します。
4. 「_Host.cshtml」ファイルに下記のリンクを適切な位置に追記します。

```html:_Host.cshtml
    <link href="libs/css/handsontable.full.min.css" rel="stylesheet" />
    <link href="libs/css/scripts.css" rel="stylesheet" />

    <script src="libs/scripts/handsontable.full.min.js"></script>
    <script src="libs/scripts/scripts.js"></script>
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/42d22161-7b9b-ddd1-706f-7ef8fcd36560.png)

【2022/12/23追記】
Blazorの機能を使ってグローバルスコープに現れない方法を知りました。

https://qiita.com/jsakamoto/items/7d9ebb29dd0c6fbfb432

## scripts.cssの中身
個人的に変更したいCSSを記述します。

```css:scripts.css
.handsontable th,
.handsontable td {
    padding: 2px 10px 2px 10px;
    font-size: 16px;
    text-align: center;
}
.handsontable th:last-child {
    padding-left: 8px;
    text-align: left;
}
.handsontable td:first-child {
    background: #EEE;
}
```

## scripts.jsの中身
```js:scripts.js
function createGrid() {
    let data = [
        ['', false, 'S0001', 'りんご', 100, '青森産'],
        ['', false, 'S0002', 'みかん', 80, '静岡産'],
        ['*', true, 'S0003', 'メロン', 1000, '袋井クラウンメロン']
    ];

    let grid = document.getElementById('grid');
    window.hot = new Handsontable(grid, {
        data: data,
        colHeaders: ['編集', '選択', '商品CD', '商品名', '単価', '備考'],
        columns: [
            { readOnly: true, type: 'text' },
            { type: 'checkbox' },
            { type: 'text' , width: 80 },
            { type: 'text' , width: 200, className: "htLeft htMiddle" },
            { type: 'numeric', numericFormat: { pattern: '0,00', culture: 'ja-JP' }},
            { type: 'text' , width: 300, className: "htLeft htMiddle" }
        ],
        enterMoves: { row: 0, col: 1 },
        outsideClickDeselects: true,
        manualColumnResize: true,
        fillHandle: false
    });
}
```

【2023/03/18追記】
JavaScript を書かないで C# のみで実現している記事を見つけました。

:::note warn
【2023/06/13追記】
Blazor WebAssembly 専用になります。
Blazor Server では一部機能が使えますが、自分の場合は ToJS で Cast Error になったりしました。
:::

https://ishikawa-tatsuya.hatenablog.com/entry/2023/02/26/172207

https://ishikawa-tatsuya.hatenablog.com/entry/2023/03/03/131102

## Index.razorにグリッド用タグ配置
グリッドを表示する場所に`<div id="grid"></div>`をセットします。
id名は変更可能ですが、scripts.js内のgetElementById('grid')と一致させる必要があります。

```html:Index.razor
@page "/"
@inject IJSRuntime jsRuntime

<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<div id="grid"></div>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
  　      if (!firstRender) return;
  　　　　
　　　　　　　　    await jsRuntime.InvokeVoidAsync("createGrid");
    }
}
```

## 実行1回目
Handsontableの内容が表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/985b6f76-d7b9-4132-7e5d-05b9267a7d1c.png)

# データ読み込みについて
先程はscripts.jsに直接データを埋め込んでいますが、やはり外だしにしたいですよね。
今度はJSONデータを生成して、読み込ませるようにしてみましょう。
columnsのdataオブジェクトに列名をセットします。

:::note info
C#と違ってJSONではキャメルケースが一般的なので列名はキャメルケースにしています。 
:::

これね、実はハマりました。
データは追加されているのに中身が表示されなくて空欄のまま。ログを出してキー名がキャメルケースになっていたので、constの定義名をパスカルケースからキャメルケースに変更しました。

## scripts.jsの変更
```js:scripts.js
function createGrid() {
    const COL_EDIT = 'edit';
    const COL_SELECT = 'select';
    const COL_PRODUCTCODE = 'productCode';
    const COL_PRODUCTNAME = 'productName';
    const COL_UNITPRICE = 'unitPrice';
    const COL_COMMENT = 'comment';
    const EDIT_MARK = '*';

    let grid = document.getElementById('grid');
    window.hot = new Handsontable(grid, {
        data: [],
        colHeaders: ['編集', '選択', '商品CD', '商品名', '単価', '備考'],
        columns: [
            { data: COL_EDIT, readOnly: true, type: 'text' },
            { data: COL_SELECT, type: 'checkbox' },
            { data: COL_PRODUCTCODE, type: 'text', width: 80 },
            { data: COL_PRODUCTNAME, type: 'text', width: 200, className: "htLeft htMiddle" },
            { data: COL_UNITPRICE, type: 'numeric', numericFormat: { pattern: '0,00', culture: 'ja-JP' } },
            { data: COL_COMMENT, type: 'text', width: 300, className: "htLeft htMiddle" }
        ],
        enterMoves: { row: 0, col: 1 },
        outsideClickDeselects: true,
        manualColumnResize: true,
        fillHandle: false,
        afterChange: function (changes, source) {
            if (source === 'loadData') return;
            for (var i = 0; i < changes.length; i++) {
                var change = changes[i];
                // 編集と選択は対象外
                if (change[1] === COL_EDIT || change[1] === COL_SELECT) continue;
                // 変更前と変更後が同じは対象外
                if (change[2] === change[3]) continue;
                // 編集に"*"を付ける
                hot.setDataAtCell(changes[0][0], 0, EDIT_MARK);
            }
        }
    });
}
// データセット
function loadData(data) {
    window.hot.loadData(data);
}
```

### ポイント
Handsontableのインスタンス変数を最初に`var hot = new Handsontable`としました。
しかし、これだとloadData関数内で`hot`変数を使用すると「Error: Microsoft.JSInterop.JSException: hot is not defined」となってしまいます。
原因は`var`が関数スコープだからです。
グローバルスコープにする必要があるため、 `windows.hot = new Handsontable`に変更することで動作しました。

【2022/12/23追記】
Blazorの機能を使ってよりよく改良して頂きました。

https://qiita.com/jsakamoto/items/654f8fbde25c44e54eb9

https://qiita.com/jsakamoto/items/6d820ef3c15e8d51f384

https://qiita.com/jsakamoto/items/7d9ebb29dd0c6fbfb432
## Index.razorの変更
本来はデータベースからデータを取得して表示するのですが、今回は例なので`Index.razor`にそのまま記載しています。

```cs:Index.razor
@page "/"
@inject IJSRuntime jsRuntime
@using System.Text.Json

<PageTitle>Index</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<SurveyPrompt Title="How is Blazor working for you?" />
<div id="grid"></div>

@code {
    public class ProductMaster
    {
        public string? Edit { get; set; }
        public bool Select { get; set; }
        public string? ProductCode { get; set; }
        public string? ProductName { get; set; }
        public int UnitPrice { get; set; }
        public string? Comment { get; set; }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        await jsRuntime.InvokeVoidAsync("createGrid");
        List<ProductMaster> products = new List<ProductMaster>()
        {
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0001", ProductName = "りんご", UnitPrice = 100, Comment = "青森産" } },
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0002", ProductName = "みかん", UnitPrice = 80, Comment = "静岡産" } },
            { new ProductMaster() { Edit = "", Select = true,  ProductCode = "S0003", ProductName = "メロン", UnitPrice = 1000, Comment = "袋井クラウンメロン" }　}
        };

        await jsRuntime.InvokeVoidAsync("loadData", products);
   }
}
```

【2022/12/22追記】
コメントを頂きました。JSONにこだわる必要がなかったんですね。上記プログラムは変更済みです。

```cs:変更前
        string jsonString = JsonSerializer.Serialize<List<ProductMaster>>(products);
        var jsonData = JsonSerializer.Deserialize<List<ProductMaster>>(jsonString);
        await jsRuntime.InvokeVoidAsync("loadData", jsonData);
```

```cs:変更後
        await jsRuntime.InvokeVoidAsync("loadData", products);
```


【2022/12/17追記】
ボタンを追記してメッセージ(alert)を出すようにしたら、再描画した段階でHandsontableがもう一つ追加されてしまいました。
その為、`if (!firstRender) return;`を追加しました。最初の描画時のみHandsontableを作成する必要がありました。

```cs
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
```

## 実行2回目
データを読み込ませて表示できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/f0592b75-9171-064e-61cc-15b3d81d01ee.png)

### 編集
Handsontableの`afterChange`関数にて、編集があった場合に「*」を付けるようにしています。
試しに金額を変更してみましょう。メロンの金額を1000円から980円にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/14053/d16ecb2a-48e0-3b37-8237-8c3cc5d7369f.png)

# 最後に
これでBlazorにExcelライクなグリッドJavaScriptライブラリー「Handsontable」が使用できることが分かりました。
Handsontableの各種設定は、冒頭に紹介した「[Handsontable Advent Calendar 2018](https://qiita.com/advent-calendar/2018/handsontable)」に蓄積したTipsを書いてありますので、そちらを参考にしてください。

BlazorのUIフレームワークは、最初は「[MatBlazor](https://www.matblazor.com/)」にしようとボタンなど書いてみたのですが、`primary`, `secondary`などの色指定とか[定義](https://github.com/SamProf/MatBlazor/issues/624)しないと使えないのが気に食わないんですよね。
これまでBoostrapで開発してきたので、似た感じの「[Ant Design Blazor](https://antblazor.com/en-US/components/overview)」に乗り換えてしまいました。

# 参照
* [Using jQWidgets with ASP .NET Blazor](https://www.jqwidgets.com/jquery-widgets-documentation/documentation/asp-net-blazor/asp-net-blazor.htm)
* [How do I get an element by ID or Class in Blazor?](https://www.syncfusion.com/faq/blazor/javascript-interop/how-do-i-get-an-element-by-id-or-class-in-blazor)
* [Dynamically add JavaScript from Blazor components](https://www.puresourcecode.com/dotnet/blazor/dynamically-add-javascript-from-blazor-components/)