【Javascript】Knockuout.js で option のカスケード処理を実装する
2018/12/01 追記
- シンタックスハイライトを適用しました。
前書き
最近お仕事の方でKnockout.jsを使ってごにょごにょやっています。
で、あるフォーム画面を作るときにとあるselect
option
のカスケードをやりたいな、と思って色々試行錯誤したんですが、思ったより大変だったのでメモしておきます。
options
バインディングの使い方
先にoptions
バインディングについて簡単に説明しておきましょう。(日本語版)
ドキュメントを読めばわかると思うんですが、data-binding
で指定できるものは以下の通りです。
パラメータ | 概要 |
---|---|
options |
select 内のoption として使う配列。 |
optionsCaption |
select の初期表示として表示する文言。option の先頭に生成され、value は"" (空文字)になる。 |
optionsText |
options の中身がオブジェクトの場合にoption のラベルとして使用するプロパティ名。 |
optionsValue |
optionsText と同様に、options の中身がオブジェクトの場合にoption のvalue として使用するプロパティ名。 |
optionsIncludeDestroyed |
論理削除フラグ。boolean を指定する。 |
optionsAfterRender |
生成したoption に対し何らかの処理を加えるためのコールバック。 |
selectedOptions |
option の初期選択値。select にmultiple 属性を指定している場合は配列を指定すればよい。 |
valueAllowUnset |
value バインディング(日本語版)でoption 内に存在しない値が指定されたときの挙動を制御する。false (デフォルト値)だと、value でバインディングしたものにoption に指定されていない値が指定されると強制的にundifined へ上書きするが、true ならばちゃんとその値がvalue として保持される。(ただし、select の見た目は空白になる。)*1 |
公式で紹介されている方法(with
バインディング)
公式ページのデモの一つにCart editorなるものがあります。(日本語版)
ここで使われている方法を使えば比較的簡単かつ直感的にカスケード処理を実装することができます。と言うか、このデモでやってることがカスケードそのものです。
ただちょっと内容が豪華すぎて本当に知りたい部分がわかりにくいので、もうちょっと簡素な形で作ってみましょう。JSFiddleも用意してあるので動作確認はそちらでどうぞ。
まずはこんな ViewModel を用意します。
function Parent(label, value, children) { this.label = label; this.value = value; this.children = ko.observableArray(children); } function Child(label, value) { this.label = label; this.value = ko.observable(value); } function ViewModel() { var self = this; this.opts = ko.observableArray([ new Parent("Parent1", 1, [new Child("Child1", 1), new Child("Child2", 2)]), new Parent("Parent2", 2, [new Child("Child3", 3), new Child("Child4", 4)]) ]); this.parentSelected = ko.observable(); this.childSelected = ko.observable(); this.checkValue = function() { var vParent = self.parentSelected() ? self.parentSelected().value : "undifined"; var vChild = self.childSelected() ? self.childSelected() : "undifined"; alert("Parent: " + vParent); alert("Child: " + vChild); }; } ko.applyBindings(new ViewModel());
で、HTML はこんな感じです。
<table> <tr> <td>Parent</td> <td> <select name="parent" data-bind="options: opts , optionsText: 'label' , value: parentSelected , optionsCaption: '選択してください'" > </select> </td> </tr> <tr> <td>Children</td> <td data-bind="with: parentSelected"> <select name="child" data-bind="options: children , optionsText: 'label' , optionsValue: 'value' , optionsCaption: '選択してください' , value: $parent.childSelected" > </select> </td> </tr> </table> <button data-bind="click: checkValue">値確認</button>
流れとしてはこんな感じですね。
ParentのoptionがViewModel.optsの配列でセットされる (表示される文言はParent.label、値はParent.value) ↓ Parentで選択した値(Parentクラス)がViewModel.parentSelectedにセットされる ↓ select#childにparentSelectedが渡される ↓ optionsにParent.childrenがセットされ、表示される文言はChild.label、値はChild.valueの値になる。 **ここで選択された値をViewModel.childSelectedに反映させるため、valueは$parent.childSelectedにする。**
カスケードされるselect
を最初から表示する
上記の方法ではselect#parent
が選択されるまでselect#child
は非表示の状態になっています。
何らかの理由でselect#child
を先に表示させておきたい場合は、if
を使って初期表示用のselect
を html に書いてしまいましょう。
visible
でも見た目上同じことができますが、select#child
が DOM 上に二つ存在することになってしまいます。if
であれば DOM の再作成 / 削除を行ってくれるのでその心配もなくなります。form
使わないならどっちでもいいですが。
(JSFiddle)
<table> <tr> <td>Parent</td> <td> <select name="parent" data-bind="options: opts , optionsText: 'label' , value: parentSelected , optionsCaption: '選択してください'" > </select> </td> </tr> <tr> <td>Children</td> <!-- ko if: parentSelected() --> <td data-bind="with: parentSelected"> <select name="child" data-bind="options: children , optionsText: 'label' , optionsValue: 'value' , optionsCaption: '選択してください' , value: $parent.childSelected" > </select> </td> <!-- /ko --> <!-- ko ifnot: parentSelected() --> <td> <select name="child" disabled> <option value="">先にParentを選択してください</option> </select> </td> <!-- /ko --> </tr> </table> <button data-bind="click: checkValue">値確認</button>
親要素が選択されていない場合は全子要素を選択できるようにする
これも初期表示用のselect
とカスケード用のselect
を作ってしまいましょう。
あわせて事前に全子要素を持った配列を作ってもいいですが、ko.computed
を使った方がスマートですし変更にも強いです。
// ViewModelに以下を追加 this.allChildren = ko.computed(function() { // flatten return [].concat.apply( [], self.opts().map(function(x) { return x.children(); }) ); });
<!-- ko if: parentSelected() --> <td data-bind="with: parentSelected"> <select name="child" data-bind="options: children , optionsText: 'label' , optionsValue: 'value' , optionsCaption: '選択してください' , value: $parent.childSelected" > </select> </td> <!-- /ko --> <!-- ko ifnot: parentSelected() --> <td data-bind="ifnot: parentSelected()"> <select name="child" data-bind="options: allChildren , optionsText: 'label' , optionsValue: 'value' , optionsCaption: '選択してください' , value: childSelected" > </select> </td> <!-- /ko -->
form
のsubmit
と併用する
カスケードとかは Knockout.js で全部設定するけどsubmit
に関してはもうform
の機能でやってしまいたい、と言うこともあるでしょう。
もちろん可能ですが、with
バインディングを使う方法だとこれはできません…。
値の確認で使っているViewModel.checkValue
をフォームのvalue
で表示するようちょっと変更してみましょうか。(JSFiddle)
this.checkValue = function() { /* var vParent = self.parentSelected() ? self.parentSelected().value : "undifined"; var vChild = self.childSelected() ? self.childSelected() : "undifined"; */ var vParent = window.test.parent.value; var vChild = window.test.child.value; alert("Parent: " + vParent); alert("Child: " + vChild); };
で、実際に動かしてみると Parent の value は絶対にとれないと思います。
理由はこれ、非常に簡単で、select
のoptions
バインディングに Javascript のオブジェクトが指定されている場合、optionsValue
バインディングを指定しないとoption
のvalue
はすべて空文字になるからです。(実際に FireBug とかで見てみるとわかる)
んじゃあoptionsValue
を設定してやるかーとこんな感じにしてみます。(JSFiddle)
<td>Parent</td> <td> <select name="parent" data-bind="options: opts , optionsValue: 'value' , optionsText: 'label' , value: parentSelected , optionsCaption: '選択してください'" > </select> </td>
動かしてみるとどんな Parent を選択しても Children が出てこなくなります。
これまた理由は非常に簡単で、optionsValue
が設定されたことで、value
バインディングに設定したparentSelected
にはParent.value
が入ってくるからです。今回の例で言えば「1」とか「2」みたいなただの数字が渡されています。
となれば、with
バインディングされているselect#child
からしたら、options
に指定されたchildren
なんて取れるわけがない(そもそもそんなプロパティがない)ので、空白のコンボボックスを表示するしかない、と言うわけです。
じゃあどうすればいいのかってとこですが、選択された値から Array を検索してしまいます。ダサいですね。
ViewModel
にこんなコードを追加します。parentSelected
に値が入っていたらViewModel.opt.value
を検索して該当するParent
を返すようにします。(JSFiddle)
this.cascade = ko.computed(function() { var vParent = self.parentSelected(); return vParent ? self.opts().filter(function(x) { return x.value == vParent; })[0] : null; });
html はこんな感じ。
<form name="test"> <table> <tr> <td>Parent</td> <td> <select name="parent" data-bind="options: opts , optionsText: 'label' , optionsValue: 'value' , value: parentSelected , optionsCaption: '選択してください'" > </select> </td> </tr> <tr> <td>Children</td> <td data-bind="if: cascade"> <select name="child" data-bind="options: cascade().children , optionsText: 'label' , optionsValue: 'value' , optionsCaption: '選択してください' , value: childSelected" > </select> </td> </tr> </table> <button data-bind="click: checkValue">値確認</button> </form>
これで値確認用のボタンをクリックすればちゃんと Parent も Child も値が取得できます。
まとめ
と言うわけで、ここ最近得た知見をいろいろまとめました。