ユーザーエクステンションを作成しArchitect 3に統合する (Part I)
こんにちは、ゼノフィnakamuraです。
どの様なアプリケーションを作成していても、ほとんどの場合はプロジェクトやグループを超えて利用できる、カスタムコンポーネントやユーザーエクステンションを生成する余地があります。活気のあるSenchaユーザーコミュニティに囲まれた、豊富なユーザーエクステンションのエコシステムがあります。開発者がコミュニティと作品を共有することで、他の開発者により早く作業を完了させる能力を与えている事を見ると嬉しいです。我々がユーザーエクステンションのAPIを作成しているときに検討したことをここで紹介します。
この記事はユーザーエクステンションを作成して、それをArchitectに統合することに関する、2話のうちのパート1となります。堅牢なユーザーエクステンションの作成に関する難題を説明して、次の記事で全てを組み立てて、Architectで再利用できるようにパッケージします。
アプリケーションを作成しているときは、ユーザーエクステンションになるべきコンポーネントが即座に見分けられないことがあります。また、自分自身の行動が繰り返していることや、それをユーザーエクステンションにリファクタする必要があることを認識するまでに、数回の実装する必要があるかもしれません。よくあるユースケースとしてLinked(またはChained)ComboBoxesを選びました。Chained Comboが意味するものは次の例です: ある人が車のパーツを購入するには、車のメーカー、モデル、トリム(内装)を決めなければならない状況を想像して下さい。その人がオプションを選択する度に、次のコンボボックスでの選択肢が制限されていきます。これは開発者が異なる方法を使って、繰り返し実装していることを、よく表した例です。また、Architect内・外で利用されるユーザーエクステンションがラッピングできることをよく示す例でもあります。
カスタムコンポーネントを開発している時は、多数の人に役に立ちつつ、APIを複雑にしすぎないために、いくつかのユースケースを検討する必要がります。コンフィグ同士が相容れないことがあります(もしくは、一つのコンフィグにある値がセットされている時には、特定のコンフィグのみが有効になる場合もあります)。コツは、コンポーネントがほとんどの状況に十分フィットできるように設定可能にしつつ、開発者の特定のユースケースの設定ができなくなるほどに複雑にしないことです。これはAPIデザイナーが全員関わる共通の難題です。ある言語は他の言語よりより構成的にかたくなるように勧めますが、これは、JavaScriptでありヴォルテールが言ったように「より強い力にはより大きな責任が伴う」のです。
LinkedComboContainerと呼ばれる新しいコンポーネントの開発中に一般化したかったものを紹介します:
- データ検索:各ComboBoxで表示されているオプションはReader(JSON、XML、Arrays、その他)経由でどのフォーマットでも利用可能で、プロキシを経由でどのソースからでも取得できる(Ajax、JSON-P、Direct、その他)ようにします。さらに、前もって全てのデータをロードするか、要求されたときにデータを取得する、ということを可能にします。
- リンクされたComboBoxの数:開発者はリンクされたComboを何個でも一緒に構成できるようにします。3や4のように固定された数字ではないようにします。
- 開発者は自分のComboBoxのサブクラスを利用ようにします:開発者が自分のComboBoxを利用できるようにしました。我々のユーザーエクステンションは、ユーザーが自分のComboBoxを利用するために、更にもう一つのクラスをサブクラスにする必要がないはずです。
- LinkedComboの表示:次のアクティブComboが隠れている状態から表示させますか?それとも無効な状態から有効にして展開させますか?ユーザーがComboBoxがどう展開するかを設定できるべきです。
- 様々なボックスレイアウトオプションに対応する:LinkedComboContainerはvboxやhboxなどどのレイアウトでも利用できるようにします。
- ComboBoxのコンフィグ:ComboBoxのコンフィグは全て使用可能とさせました。例えば、displayField, valueField, tplなどがセットできるようにします。
下記では、できるだけ多くの環境にフィットするように、ComboBoxをロードするための複数なアプローチに対応したことを説明します。この記事はExt.defineとカスタムコンポーネントの生成の基本をご存知であることを前提として書かれています。
各ComboBoxの独立したストア
データをロードする最初のアプローチには、開発者がいくつかの異なるエンドポイントに行く必要があります。各ストアのデータは異なるプロキシ、リーダー、モデルかもしれません。開発者は各ComboBoxで自分のストアを定義でき、LinkedComboContainerは選択される度に次のストアのローディングの管理をします。
下記は、2000年の自動車のメーカー、モデル、トリムを取得するCarQueryAPIという実際のJSON-P APIで動作する短いコードスニペットです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | var carQueryApiUrl = 'http://www.carqueryapi.com/api/0.3/?year=2000&sold_in_us=1&'; var makeStore = new Ext.data.Store({ proxy: { type: 'jsonp', url: carQueryApiUrl + 'cmd=getMakes', reader: { root: 'Makes', type: 'json' } }, fields: ['make_id','make_display'] }); var modelStore = new Ext.data.Store({ proxy: { type: 'jsonp', url: carQueryApiUrl + 'cmd=getModels', reader: { root: 'Models', type: 'json' } }, fields: ['model_name'] }); var trimStore = new Ext.data.Store({ proxy: { type: 'jsonp', url: carQueryApiUrl + 'cmd=getTrims', reader: { root: 'Trims', type: 'json' } }, fields: [{ name: 'model_trim', convert: function(v, record) { // Some vehicles only come in one trim and the API returns "". // Lets provide a meaningful trim option. return v || 'Standard'; } }] }); var carComboCt = Ext.create('Ext.ux.LinkedComboContainer', { // configs to be passed to all combos created. defaultComboConfig: { width: 350 }, comboConfigs: [{ name: 'make', valueField: 'make_id', displayField: 'make_display', fieldLabel: 'Choose a Car', store: makeStore },{ name: 'model', valueField: 'model_name', displayField: 'model_name', store: modelStore },{ name: 'trim', displayField: 'model_trim', valueField: 'model_trim', store: trimStore }] }); |
CarQueryAPIあての各APIの呼び出しでは、異なるデータセットと異なるモデルが返されます。各ComboBoxに違うストアを利用する必要がある、良い例となります。次はLotusとExigeを選択すると取得されるデータを示す実際の例となります。
まず最初は2000年の全てのメーカーを取得します。
api/0.3/?year=2000&sold_in_us=1&cmd=getMakes&field=make&callback=Ext.data.JsonP.callback1 |
次に2000年でメーカーがLotusの全てのモデルを取得します。
api/0.3/?year=2000&sold_in_us=1&cmd=getModels&_dc=1386628784888&field=model&make=lotus&callback=Ext.data.JsonP.callback2 |
そして2000年のLotus Exigeの可能なトリムを取得します。
api/0.3/?year=2000&sold_in_us=1&cmd=getTrims&field=trim&make=lotus&model=Exige&callback=Ext.data.JsonP.callback3 |
一つのマスターストア
次のアプローチでは、開発者が同じエンドポイントから必要な時にComboBoxのデータを全てロードできるようになります。選択されたら、一連の操作で行った以前の選択とともに、その時のフィールドが送られます。例えば、同じAjaxエンドポイント getOptions.php からデータを検索したい場合は、次のコードスニペットで行えます:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | var masterMockStore = new Ext.data.Store({ proxy: { type: 'ajax', url: 'getOptions.php', reader: { type: 'array' } }, fields: ['name','value'] }); var mockLinkedCombo = Ext.create('Ext.ux.LinkedComboContainer', { // master store to load all combos from store: masterMockStore, // configs to be passed to all combos created. defaultComboConfig: { displayField: 'name', valueField: 'value' }, comboConfigs: [{ name: 'parentOption', fieldLabel: 'Parent Option' },{ name: 'childOption', fieldLabel: 'Child Option' }], listeners: { subselect: function(comboCt, value, combo) { // console.log('Chose ' + combo.getValue() + ' from ' + combo.name); }, select: function(comboCt, value, combo) { Ext.Msg.alert("A Fine Selection!", Ext.encode(value)); } } }); |
この設定のリクエストは次のようになります:
getOptions.php?field=parentOption |
このコードで全てのparentOptionが返されます。
選択後のリクエストは次のようになります:
getOptions.php?field=childOption&parentOption=optB |
このコードはparentOptionがoptBであるchildOptionを全て返します。 各追加のsubselect(Comboのチェーンから一つのオプションを選択すること)で、以前選択されたフィールドを含む新しいリクエストが送られます(全てが完成されるまで)。
事前に全てのデータをリストでロードする
データセットが小さいときは、前もって全てのデータをロードして、LinkedComboContainerがクライアントサイドで全てのフィルタリングを行うようにするのが良いでしょう。リストにデータをロードすると、ストアの定義が一つですみますし、データも一回でロードすることができます。
リストに定義されるデータは次のようになります:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | [{ state: 'CA', county: 'San Mateo', city: 'San Mateo' },{ state: 'CA', county: 'San Mateo', city: 'Burlingame' },{ state: 'CA', county: 'Santa Clara', city: 'Palo Alto' },{ state: 'CA', county: 'Santa Clara', city: 'Sunnyvale' },{ state: 'MD', county: 'Frederick', city: 'Frederick' },{ state: 'MD', county: 'Frederick', city: 'Middletown' },{ state: 'MD', county: 'Carroll', city: 'Westminster' },{ state: 'MD', county: 'Carroll', city: 'Eldersburg' }] |
次はそのフラットリストを使う例です:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | var stateCountyStore = new Ext.data.Store({ proxy: { type: 'ajax', url: 'aslist.json', reader: { type: 'json' } }, fields: ['state','county','city'] }); var stateCountyCombo = Ext.create('Ext.ux.LinkedComboContainer', { // master store to load all combos from store: stateCountyStore, loadMode: 'aslist', comboConfigs: [{ name: 'state', displayField: 'state', valueField: 'state', fieldLabel: 'State' },{ name: 'county', displayField: 'county', valueField: 'county', fieldLabel: 'County' },{ name: 'city', displayField: 'city', valueField: 'city', fieldLabel: 'City' }] }); |
これがComboBoxのチェーンにデータを挿入する最も簡単な方法ですが、クライアントサイドに最も大きく負担をかけます。 これを最適化するためにフィルタリングの時にresultsetをキャッシュすることができます。 LinkedComboContainer.jsのgetSubListRecordsをご覧下さい。
事前にデータをTreeでロードする
ユーザーエクステンションと取り込むデータを取得するためのもう一つの方法があります。 データをTree形式でロードすることです。 今後、これを劇的に簡単にする改善がExt JSに予定されていますので、その時にこの話をより細かく説明します。
フレキシブルデータの実装
こんなにもフレキシブルにデータとバインドできるウィジェットを作成できることは、我々のフレームワークのデータパッケージの強力さを表しています。欲しかった機能全てを実現するためには、ほんのいくつかの難しい作業がありました。
一つのストアコンフィグを使っている時にはLinkedComboContainerは同じモデルを利用して各ComboBoxにストアを生成します。
LinkedComboContainerはの内部の “userProvidedStore” フラグによって、ユーザーがComboBoxを生成した時にストアを提供したかどうかを追跡します。このため、データをマスターストアからロードするのか、ストアから直接ロードするのかが分かります。
1 | userProvidedStore: !!userComboConfig.store, |
ストアの新しいインスタンスを生成するときに、マスターストアと同じモデルを利用します。modelNameを利用することで、クラスが定義した特定のモデル、または単にfieldsコンフィグで定義された暗黙的なモデル、のコピーができます。
1 2 3 4 5 6 7 | var store = new Ext.data.Store({ proxy: { type: 'memory' }, // we are re-using the model from the master store model: this.store.model.modelName }); |
本来の目的を達成する
LinkedComboContainer.jsのコード をご覧になると、追加のストアクラスやカスタムのComboBoxサブクラスを生成しなかったことが分かります。 またオーバーライドも利用しませんでした。 このお陰で、開発者が簡単に自分のカスタムComboBox、Stores、Reader、Proxyを利用しながら、コードベースにコンポーネントを統合できます。 しかし、小さい問題が発生しました。LinkedComboContainerはComboBoxのトリガーがいつクリックされたか知る必要があったのです。デザインにより、トリガーがクリックされた時に、発生するイベントがないのです。 TriggerFieldの一つひとつのサブクラスは通常はonTriggerClickを実装します。 このため、LinkedComboContainerに追加されたComboBoxの各onTriggerClickをハイジャックする必要がありました。 これによって、ユーザーのonTriggerClick実装方法に関係なく、LinkedComboContainerのコードもまた動作します。
1 2 3 | // no events are exposed onTriggerClick and we'd like developers to be able to use any subclass // of combobox without subclassing a custom one, therefore we hijack triggerClick combo.onTriggerClick = Ext.Function.createInterceptor(combo.onTriggerClick, this.onBeforeComboTriggerClick, this); |
onBeforeComboTriggerClickはLinkedComboContainerのコンテキストで動作します(つまりthisはLinkedComboContainerを指します)。どのcomboがリクエストを生成したか見つけ出すために、createInterceptorでつけられた .target プロパティを確認する必要があります。
1 2 3 4 5 6 7 8 9 10 11 | onBeforeComboTriggerClick: function() { // grab the combo that generated the triggerClick via .target (tagged on by Ext.Function.createInterceptor) var combo = arguments.callee.target, comboIndex = this.combos.indexOf(combo); // the first combo is the only one that load is triggered by clicking on the combo trigger // all of the others are loaded once a selection has been made. if (comboIndex === 0 && combo.store.getCount() === 0) { this.doLoad(combo); } }, |
注:arguments.calleeはES5のstrictモードでは使用可能ではありません。Internet Explorer対応を保つために名称付き関数のようなものを利用するよりも、これを利用することに決めました。
次回は
この記事でカスタムコンポーネント開発とAPIの設計に対して発生する問題について説明できたなら嬉しいです。次の記事ではLinkedComboContainerを、Architect User Extensionでドラッグアンドドロップの構成にするためにラップしてみます。LinkedComboContainerに追加する便利な機能を思いつきますか?よく似たものをコーディングしたことがありますか? 一回の実装として行いましたか、それともカスタムコンポーネントを作成しましたか? 教えて下さい。