CDNのReduxを利用してkintone UI Componentを状態管理する(Redux@3.6.0)

いつもreact-reduxとredux-toolkitを利用して開発しています。

しかし、とある案件でwebpackなどでのトランスパイル禁止という縛りを受けた上で、kintone UI Component利用した複雑なUI構築の必要が出ました。

せっかくなので知見をここでまとめてみようという試みです。

# kintone UI Componet (=KUC)

kintone UI ComponentはkintoneライクなUIがつかえちゃう素敵なライブラリです。略してKUC。

https://ui-component.kintone.dev/ja/

ソースを見るとLitで作られているようですね。
コンポーネント内部では状態を管理していますが、複合したコンポーネントを統合管理する方法はなさそうです。（あるかも。教えてください！）

# Redux
状態管理はいろんな方法がありますが、Reduxは一番使われるフレームワークだと思います。React開発では絶対使いますね。

ReduxであればFluxパターンを利用して安全に管理が行えると思います。

https://redux.js.org/

他にもVueのreactiveとかZustand,Overmindといったフレームワークがありますね。

Reactで開発する場合は、redux-toolkitを使っています。

https://redux-toolkit.js.org/

ですが今回はCDNを利用するので、redux-toolkitは使えません。

# つくるもの
👇のような動きをするUIを作ってみます。
![画面操作.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/a4fdecac-edbc-eeff-a26b-918048bcf582.gif)

kintoneと連動性が高いUIを実現したいです。

# 実践
ではカスタマイズをはじめましょう。

## kintoneアプリ
kintoneのアプリをこのように作っておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/3943a676-93e7-953b-c0e0-eb6ca6da80b2.png)

上部スペースのidは`button`です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/16aa3577-f738-7001-aac7-1b0abe32af15.png)


## ReduxとKUCのインストール
cdnを利用します。
- redux
    - https://cdnjs.cloudflare.com/ajax/libs/redux/3.6.0/redux.js
- kuc
    - https://unpkg.com/kintone-ui-component/umd/kuc.min.js

Redux, Kucというグローバルプロパティが設定されます。

reduxは3.6.0であればブラウザで動作できるライブラリが提供されています。このバージョン以降はesmで読み込むしかなさそうですが、esmはkintoneではサポートされていないので、正攻法でkintoneから呼び出すのは難しそうです。

## データの設計
コーディングの前に、どのようなデータ構造がUIに必要か考えてみましょう。
画面を眺めてみると、以下のようなデータが必要そうです。
```
{
    //ダイアログのタイトル
    title: string;
    
    //選べる値の選択肢
    selectItems: string[];

    //選択された値
    selected: null | string;

    //モーダルが閉じられた時間
    closedDate: null | Date;
}    
```
このデータの更新によって、UIとkintoneを連動させれば良さそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3712902/537fabd7-6e45-66e5-6b49-b19410c98bea.png)

👆こんな感じの構造が作れればオッケー。

## 実際のコード
```javascript
/**
 * 管理するデータの構造の定義
 * @typedef {{
 *      title: string;
 *      selectItems: string[];
 *      selected: null | string;
 *      closedDate : null | Date;
 * }} AppState　
 */
(() => {
    /**
     * 最初のstoreの値を作る
     * @returns {AppState}
     */
    function createInit() {
        return {
            title: '',
            selectItems: ["あ", "い", "う"],
            selected: null,
            closedDate: null,
        }
    }

    /**
     * 更新するだけのシンプルなreducer
     * dispatch信号を受けて、状態を変更する。
     */
    function reducer(state = createInit(), action) {
        switch (action.type) {
            case 'update':
                return {
                    ...state,
                    ...action.payload
                }
            default:
                return state
        }
    }
    /**
     * 更新のためのdispatch信号を作る（よく使うから関数にしている）
     * @param {Partial<AppState>} next 
     * @returns 
     */
    function updateAction(next) {
        return {
            type: 'update',
            payload: next
        }
    }

    /**
     * reducerから状態管理機構(store)を構築する。
     */
    function createAppStore() {
        return Redux.createStore(reducer);
    }

    /**
     * UIを管理するクラス
     */
    class UserInterface {
        //ダイアログ
        $$dialog = new Kuc.Dialog();

        //ボタン
        $$kucButton = new Kuc.Button({
            text: 'open',
            id: 'qiita-button'
        });

        //ダイアログの親になるdiv。中央ぞろえしたいので。準備だけしておく。
        $$dropdownContainer = document.createElement('div');

        //ダイアログの中のドロップダウン。選択肢はstoreによって決まります。
        $$dropdown = new Kuc.Dropdown({
            label: '選べる値',
            value: '',
            items: [],
        })
        //データ（状態）の管理機構
        store = createAppStore();

        //storeとkintoneの接続を切るかどうか
        store2kintoneConnection = true;

        constructor() {
            this.$$dropdownContainer.style.height = `50vh`;
            this.$$dropdownContainer.style.width = `50vh`;
            this.$$dropdownContainer.style.display = 'flex';
            this.$$dropdownContainer.style.flexDirection = 'row';
            this.$$dropdownContainer.style.justifyContent = 'center';
            this.$$dropdownContainer.style.alignItems = 'start';
        }

        /**
         * storeの値にかかわらず再利用できるKUCオブジェクトの作成と設置
         * @param {HTMLDivElement} $$button
         */
        mount($$button) {
            //mount
            if (document.getElementById(this.$$kucButton.id) == null) {
                $$button.appendChild(this.$$kucButton);
            }

            //dialogないのコンテンツを作る
            this.$$dialog.content = this.$$dropdownContainer;
            this.$$dropdownContainer.appendChild(this.$$dropdown);

            //dialogは自動でマウントされる
        }


        /**
         * storeの値をUIに反映する。通常はrenderのような名前を付けます。
         */
        store2UI() {
            /**@type {AppState} */
            const state = this.store.getState();

            this.$$dialog.title = state.title;

            this.$$dropdown.items = state.selectItems.map(value => ({
                label: value,
                value: value
            }));

            this.$$dropdown.value = state.selected;

            /* 各コンポーネントの動きをいれる。*/
            this.__ui2store();
        }

        /**
         * UIの値をstoreに反映する。
         * 普通はrenderされたときに動きが決まるので、
         * 分けないないことが多いです。
         */
        __ui2store() {
            this.$$dropdown.onchange = (ev) => {
                this.store.dispatch(updateAction({
                    selected: ev.target.value
                }))
            };

            this.$$dialog.onclose = () => {
                this.store.dispatch(updateAction({
                    closedDate: new Date()
                }))
            }
            this.$$dialog.onopen = () => {
                this.store.dispatch(updateAction({
                    closedDate: null
                }))
            }

            this.$$kucButton.onclick = () => {
                this.$$dialog.open();
            }
        }

        /**
         * storeの値をkintoneに反映する
         */
        store2kintone() {
            /**@type {AppState} */
            const state = this.store.getState();

            const rec = kintone.app.record.get();

            rec.record.モーダルのタイトル.value = state.title;
            rec.record.選べる値.value = state.selected;
            rec.record.モーダルを閉じた日時.value = state.closedDate?.toISOString();

            kintone.app.record.set(rec);
        }

        /**
         * 
         * @param {Record< string, {value : any}>} record 
         */
        kintone2store(record) {
            //storeとkintoneの接続を切る
            this.store2kintoneConnection = false;

            this.store.dispatch(updateAction({
                title: record.モーダルのタイトル.value,
                selected: record.選べる値.value,
                closedDate: null == record.モーダルを閉じた日時.value ? null : new Date(record.モーダルを閉じた日時.value)
            }));
        }


        connectKintone() {
            this.store.subscribe(() => {
                if (this.store2kintoneConnection) {
                    this.store2kintone();
                } else {
                    //つけなおす
                    this.store2kintoneConnection = true;
                }
            })
        }

        connectUI(){
            this.store.subscribe(() => {
                this.store2UI();
            });
        }

    }

    ///////////////ここから後続処理///////////////////

    const ui = new UserInterface();

    //接続on
    ui.connectKintone();
    ui.connectUI();

    //kintoneの画面が表示されたときの処理
    kintone.events.on(['app.record.create.show', 'app.record.edit.show'], async (ev) => {
        //上部のボタン用スペース
        const $$button = kintone.app.record.getSpaceElement('button');

        //マウント（UIをつくる）
        ui.mount($$button);

        ui.store.subscribe(() => console.log(ui.store.getState()));


        //初期値を反映する。
        ui.kintone2store(ev.record);

        return ev;
    });

    //kintone側のデータの変更をstoreに反映する。
    kintone.events.on([
        'app.record.create.change.モーダルのタイトル',
        'app.record.edit.change.モーダルのタイトル',
        'app.record.create.change.選べる値',
        'app.record.edit.change.選べる値',
    ], (ev) => {

        //kintoneのデータの初期値をstoreに反映
        ui.kintone2store(ev.record);

        return ev;
    });
})();
```

### createInit, reducer, updateAction, createAppStore
これらはReduxの基本的な書き方です。

store.dispatchにActionオブジェクトを信号として渡すと、状態が変わるというイメージです。updateActionはActionオブジェクトを簡単に作成するための関数です。

### mount
これは画面上で状態にかかわらず作成されるコンポーネントを構築する関数です。
ボタンやダイアログは使い捨てではなく、何度も使うので、ここで構築します。

### store2UI, __ui2store
storeとUIとを相互に反映させる処理です。

storeが保持しているstateの値を取得するにはstore.getState()を利用します。

### store2kintone,kintone2store
storeとkintoneとを相互に反映させる処理です。

気をつけなければいけないのはkintone.app.record.get/setを利用していることです。これはイベントハンドラ内では利用できないので、時々接続を切ってあげる必要があります。

接続を切るのは、kintone2storeの役割です。


### connectKintone, connectUI
storeの変更はstore.subscribeで監視することにより実現できます。これで、kintoneからのデータの変更やUIの操作による変更を契機に処理を走らせることができます。



# なぜstoreを利用するか
store2UI関数を見てください。
```javascript
/**
 * storeの値をUIに反映する。通常はrenderのような名前を付けます。
 */
store2UI() {
    /**@type {AppState} */
    const state = this.store.getState();

    this.$$dialog.title = state.title;

    this.$$dropdown.items = state.selectItems.map(value => ({
        label: value,
        value: value
    }));

    this.$$dropdown.value = state.selected;

    /* 各コンポーネントの動きをいれる。*/
    this.__ui2store();
}
```
**この関数はkintoneとは関係がない**ですよね。

同様にreduxに関連する、createInit, reducer, updateAction, createAppStoreもkintoneと関係がありません。mout関数も大丈夫です。

ということは、**kintoneなしでも動作ができる**ということです。

kintoneなしでも動作できるなら、普通のHTMLコードに乗せればテストコードが書けます。

テストされた部分は信頼性が上がるので、結合テスト中はこの部分がエラーになることはほぼありませんから、欠陥を見つけ出す速度が上がるわけです。


# まとめ
以下にポイントをまとめてみました。
- kintone UI Componentは単独では状態管理機構がない
- Reduxは3.6.0であればCDN経由で扱える
- Reduxは状態管理を行うライブラリ
    - store.getState()でリアルタイムの値を取得できる
    - store.subscribe(listener)で値の変更を監視できる
- kintoneとの状態の同期は少し工夫が必要


# kintoneのカスタマイズとUIの関係
kintoneでカスタマイズをする意義というのは特定の領域に限られます。現状関連サービスやプラグインでほぼ賄えてしまえるし、カスタマインという素晴らしいサービスやいろいろなプラグインがあるからです。

その領域の一つに「独自のUIを実装する」というものがあります。これは業務の性質に合わせて、kintoneのUIでは供給できない入出力機能を提供する目的で実施されます。

では「kintoneで供給できないUI」とはどんなものかというと、おそらく"状態管理が必要になるほど複雑な動きをするもの"じゃないかなと思っています。今回はとても簡単な例でしたが、入力値によって選択肢をフィルターしたり、色を変えたり、それを即時反映したりといったものが考えられます。

kintoneUIComponentは優れたライブラリですが、状態管理を行わなければ、kintoneで利用する意味はあまりないかなと思っています。kintoneのデフォルトのUIで作っちゃえばいいですからね。

そういったわけでReduxと組み合わせてみました。

# やってみた感想
kintoneデータとstoreの同期がやっぱり難しいなと思いました。ループしちゃうので。

---

この記事は以上です。ありがとうございます。

---

https://www.pulsar-works.jp/

kintoneのプラグイン開発や研修などを行っています。
お仕事のお話はこちらまで。