0
0

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

Posted at

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

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

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

kintone UI Componet (=KUC)

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

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

Redux

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

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

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

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

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

つくるもの

👇のような動きをするUIを作ってみます。
画面操作.gif

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

実践

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

kintoneアプリ

kintoneのアプリをこのように作っておきます。
image.png

上部スペースのidはbuttonです。
image.png

ReduxとKUCのインストール

cdnを利用します。

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

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

実際のコード

/**
 * 管理するデータの構造の定義
 * @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関数を見てください。

/**
 * 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の同期がやっぱり難しいなと思いました。ループしちゃうので。


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


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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0