いつも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は使えません。
つくるもの
kintoneと連動性が高いUIを実現したいです。
実践
ではカスタマイズをはじめましょう。
kintoneアプリ
ReduxとKUCのインストール
cdnを利用します。
- redux
- kuc
Redux, Kucというグローバルプロパティが設定されます。
reduxは3.6.0であればブラウザで動作できるライブラリが提供されています。このバージョン以降はesmで読み込むしかなさそうですが、esmはkintoneではサポートされていないので、正攻法でkintoneから呼び出すのは難しそうです。
データの設計
コーディングの前に、どのようなデータ構造がUIに必要か考えてみましょう。
画面を眺めてみると、以下のようなデータが必要そうです。
{
//ダイアログのタイトル
title: string;
//選べる値の選択肢
selectItems: string[];
//選択された値
selected: null | string;
//モーダルが閉じられた時間
closedDate: null | Date;
}
このデータの更新によって、UIとkintoneを連動させれば良さそうです。
👆こんな感じの構造が作れればオッケー。
実際のコード
/**
* 管理するデータの構造の定義
* @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のプラグイン開発や研修などを行っています。
お仕事のお話はこちらまで。