初心者のためのExtJS入門

ExtJSを使うので、ついでにまとめていきます

Googleマップ

今回はExtJSからGoogleマップを表示してみます。

classicでの使い方

classicでは、Ext.ux.GMapPanelを使います(http://docs.sencha.com/extjs/6.5.1/classic/Ext.ux.GMapPanel.html)。

まずは、をロードしておきます。これはindex.htmlか、Ext.Loader.loadScriptで読み込んでおけば良いでしょう。

そのうえで、下記のようになります。

Ext.define('Sample.view.map.Panel', {
    extend: 'Ext.ux.GMapPanel',
    xtype: 'map_panel',

    gmapType: 'map',

    center: {
        geoCodeAddr: "鹿児島県庁",
        marker: {
            title: '鹿児島県庁'
        }
    },

    mapOptions: {
        mapTypeId: google.maps.MapTypeId.ROADMAP
    }
});

f:id:sham-memo:20170909192215p:plain

ドキュメントには定義されていませんが、centerコンフィグには中心位置に関する情報を指定します。

markersコンフィグでマーカーは複数設定できます。

マーカーの追加にはaddMakerメソッド(戻り値はgoogle.maps.Marker)が用意されているようですが、削除は自力でGoogle Map APIを呼ぶ必要があるようです。

modernでの使い方

modernでは、Ext.ux.google.Mapを使います(http://docs.sencha.com/extjs/6.5.1/modern/Ext.ux.google.Map.html)。

Google Map APIの使い方をあえてclassicに合わせると、まずapp.jsonマッシュアップの設定を追加します。

app.json

"mashup": {
    "map": {
        "options": "?v=3&sensor=false"
    }
}

Ext.ux.google.Mapは、下記のような定義があり、app.jsonのoptionsの値を設定したリクエストでGoogle Map APIを利用します。

requiredScripts: [
    '//maps.googleapis.com/maps/api/js{options}'
]

あとは下記のようになります。

/**
 * マップパネルクラス。
 *
 * @class Sample.view.map.Panel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.map.Panel', {
    extend: 'Ext.Panel',
    xtype: 'map_panel',

    requires: [
        'Ext.ux.google.Map'
    ],

    layout: 'fit',

    items: {
        xtype: 'map',
        useCurrentLocation: false,
        mapOptions: {
            zoom: 16,
            center: {
                lat: 31.560236,
                lng: 130.557855
            }
        },
        markers: [
            {
                position: {
                    lat: 31.560236,
                    lng: 130.557855
                }
            }
        ]
    }
});

f:id:sham-memo:20170909192156p:plain

classicと異なり、geoCodeAddrが直接使えないみたいです。この点で少し使いにくい気がします。

最後に

ExtJSのクラスは結局のところ、Google Map APIの導入を簡単にしてくれているだけなので、必ずしも使う必要はありません。

最初に気にしておくべきは、APIのバージョンアップ時に対応できるかどうかという点だと思います。

ExtJSのマップ用クラスを使っていて、もしGoogle Map APIが大きく変わるような場合、どのように対処すれば良いか分かっている場合は採用しても問題ないでしょう。

そうでなければ、いっそ自分で組み込んだ方がその後の負担は少なくなるかもしれません。

スポットライト[classic]

今回はExt.ux.Spotlightの紹介です。

Exampleを眺めていたら見つけて試したくなりました(http://examples.sencha.com/extjs/6.5.1/examples/classic/core/spotlight.html)。

これを使うと、特定の要素を目立たせることができます。

使い方

Ext.ux.Spotlightのインスタンスで目立たせたい要素を引数にしてshowメソッドを実行します。

var button1 = panel.down('#button1'),
    spot = Ext.create('Ext.ux.Spotlight');

spot.show(button1.getEl());

すると、下記のようにボタン1をマスク表示で囲うような表現となります。

f:id:sham-memo:20170903141112p:plain

スポットライト用のスタイルが見当たらなかったので、自分で定義する必要があるようです。

ここでは下記のようにしています。

.#{$prefix}spotlight {
  background-color: #999;
  z-index: 8999;
  position: absolute;
  top: 0;
  left: 0;
  @include opacity(.5);
  width: 0;
  height: 0;
  zoom: 1;
  font-size: 0;
}

応用

サービスへの初回ログイン時のナビゲーションに使えそうなので、それっぽいものを試してみました。

f:id:sham-memo:20170903142205g:plain

gifにしたので画質が悪くなっています。。。

しかし、それなりに形になりました。これから作るアプリケーションでは組み込んでいこうと思います。

メインのビュー

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    controller: 'sample_panel',

    cls: 'sample-panel',

    layout: 'fit',

    listeners: {
        afterlayout: 'onAfterLayout'
    },

    dockedItems: {
        xtype: 'toolbar',
        cls: 'nav-toolbar',
        items: [
            {
                xtype: 'label',
                cls: 'brand',
                text: 'ロゴ'
            },
            '->',
            {
                reference: 'envelope_button',
                iconCls: 'x-fa fa-envelope',
                scale: 'large'
            },
            {
                reference: 'comment_button',
                iconCls: 'x-fa fa-comment',
                scale: 'large'
            },
            {
                reference: 'info_button',
                iconCls: 'x-fa fa-info-circle',
                scale: 'large'
            }
        ]
    },

    items: {
        xtype: 'panel'
    }
});

Ext.define('Sample.view.sample.PanelController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.sample_panel',

    requires: [
        'Ext.ux.Spotlight'
    ],

    helpRefs: [
        'envelope_button',
        'comment_button',
        'info_button'
    ],

    helpInfo: {
        'envelope_button': {
            text: 'メンテナンスや新機能追加のお知らせは、こちらからご確認いただけます。',
            align: 'tr-br',
            offset: [0, 10]
        },
        'comment_button': {
            text: 'ユーザからのコメントは、こちらからご確認いただけます。',
            align: 'tr-br',
            offset: [0, 10]
        },
        'info_button': {
            text: '分からない機能があれば、このボタンからヘルプをご確認いただけます。',
            align: 'tr-br',
            offset: [0, 10]
        }
    },

    config: {
        /**
         * @cfg {Ext.ux.Spotlight} スポットライトオブジェクト。
         */
        spot: null,

        /**
         * @cfg {Number} 現在表示しているヘルプ参照のインデックス。
         */
        currentHelpIndex: null,

        /**
         * @cfg {Sample.view.balloon.Panel} 吹き出しパネル。
         */
        balloon: null
    },

    /**
     * afterlayoutイベント時の処理。
     */
    onAfterLayout: function () {
        this.runHelp();
    },

    /**
     * ヘルプの表示を開始する。
     */
    runHelp: function () {
        var me = this,
            refInfo = me.currentHelpRefInfo();

        me.showHelp(refInfo);
    },

    /**
     * 前のヘルプを表示する。
     */
    showPrevHelp: function () {
        var me = this,
            refInfo = me.prevHelpRefInfo();

        me.showHelp(refInfo);
    },

    /**
     * 次のヘルプを表示する。
     */
    showNextHelp: function () {
        var me = this,
            refInfo = me.nextHelpRefInfo();

        me.showHelp(refInfo);
    },

    /**
     * ヘルプを表示する。
     * @param {object} helpRefInfo ヘルプ参照情報
     */
    showHelp: function (helpRefInfo) {
        var me = this,
            spot = me.getSpot(),
            ref = helpRefInfo.ref,
            target = me.lookupReference(ref),
            helpInfo = me.helpInfo[ref];

        me.setCurrentHelpIndex(helpRefInfo.index);

        // 前の吹き出しが残っていたら破棄する
        me.destroyPreBalloonPanel();

        // スポットライトを当てる
        spot.show(target.getEl());

        // 吹き出し表示
        me.showBallonPanel({
            target: target,
            text: helpInfo.text,
            first: helpRefInfo.first,
            last: helpRefInfo.last,
            align: helpInfo.align,
            offset: helpInfo.offset
        });
    },

    /**
     * 吹き出しを表示する。
     * @param {object} params パラメータ
     */
    showBallonPanel: function (params) {
        var me = this;

        Ext.defer(function () {
            var balloonPanel = Ext.widget('sample_balloon_panel', {
                html: params.text,
                first: params.first,
                last: params.last,
                listeners: {
                    prev: 'onPrevBalloonPanel',
                    next: 'onNextBalloonPanel',
                    end: 'onEndBalloonPanel',
                    destroy: 'onDestroyBalloonPanel',
                    scope: me
                }
            });

            me.setBalloon(balloonPanel);

            balloonPanel.showBy(params.target, params.align, params.offset);
        }, params.first ? 500 : 100);
    },

    /**
     * 前の吹き出しを破棄する。
     */
    destroyPreBalloonPanel: function () {
        var me = this,
            balloon = me.getBalloon();

        if (balloon) {
            balloon.destroy();
        }
    },

    /**
     * 吹き出しパネルprevイベント時の処理。
     * @param {Sample.view.balloon.Panel} panel 吹き出しパネル
     */
    onPrevBalloonPanel: function (panel) {
        var me = this;

        panel.destroy();

        me.showPrevHelp();
    },

    /**
     * 吹き出しパネルnextイベント時の処理。
     * @param {Sample.view.balloon.Panel} panel 吹き出しパネル
     */
    onNextBalloonPanel: function (panel) {
        var me = this;

        panel.destroy();

        me.showNextHelp();
    },

    /**
     * 吹き出しパネルendイベント時の処理。
     * @param {Sample.view.balloon.Panel} panel 吹き出しパネル
     */
    onEndBalloonPanel: function (panel) {
        var me = this,
            spot = me.getSpot();

        panel.destroy();

        me.setCurrentHelpIndex(null);

        spot.hide();
    },

    /**
     * 吹き出しパネルdestroyイベント時の処理。
     */
    onDestroyBalloonPanel: function () {
        this.setBalloon(null);
    },

    /**
     * 現在の参照情報を返す。
     * @return {object} 現在の参照情報
     */
    currentHelpRefInfo: function () {
        var me = this,
            currentHelpIndex = me.getCurrentHelpIndex();

        if (currentHelpIndex === null) {
            currentHelpIndex = 0;
        }

        return me.getHelpRefInfo(currentHelpIndex);
    },

    /**
     * 前の参照情報を返す。
     * @return {object} 前の参照情報
     */
    prevHelpRefInfo: function () {
        var me = this,
            currentHelpIndex = me.getCurrentHelpIndex(),
            index;

        if (currentHelpIndex > 0) {
            index = currentHelpIndex - 1;
        } else {
            index = 0;
        }

        return me.getHelpRefInfo(index);
    },

    /**
     * 次の参照情報を返す。
     * @return {object} 次の参照情報
     */
    nextHelpRefInfo: function () {
        var me = this,
            currentHelpIndex = me.getCurrentHelpIndex(),
            index;

        if (currentHelpIndex === null) {
            index = 0;
        } else if (currentHelpIndex >= 0) {
            index = currentHelpIndex + 1;
        }

        return me.getHelpRefInfo(index);
    },

    /**
     * 指定されたインデックスのヘルプ参照情報を返す。
     *
     * @param {number} index インデックス
     * @return {object} ヘルプ参照情報
     */
    getHelpRefInfo: function (index) {
        var me = this,
            helpRefs = me.helpRefs,
            refInfo = {
                ref: null,
                first: false,
                last: false,
                index: index
            };

        if (index === 0) {
            refInfo.first = true;
        } else if (index === helpRefs.length - 1) {
            refInfo.last = true;
        }

        refInfo.ref = helpRefs[index];

        return refInfo;
    },

    // @override
    getSpot: function () {
        var me = this,
            spot = me.callParent(arguments);

        if (!spot) {
            spot = Ext.create('Ext.ux.Spotlight', {
                easing: 'easeOut',
                duration: 300
            });
            me.setSpot(spot);
        }

        return spot;
    }
});
@charset "UTF-8";

.sample-panel {
  .nav-toolbar {
    background-color: $base-color;

    .brand {
      color: #fff;
    }
  }
}

.#{$prefix}spotlight {
  background-color: #999;
  z-index: 8999;
  position: absolute;
  top: 0;
  left: 0;
  @include opacity(.5);
  width: 0;
  height: 0;
  zoom: 1;
  font-size: 0;
}

吹き出し部分

ビューモデルやビューコントローラも使わず、スタイルも吹き出しっぽくしてなくて、結構手抜きです。

Ext.define('Sample.view.sample.balloon.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_balloon_panel',

    cls: 'sample-balloon-panel',

    floating: true,
    bodyPadding: 15,
    maxWidth: 300,

    // @override
    initComponent: function () {
        var me = this;

        Ext.apply(me, {
            buttons: [
                {
                    text: 'Prev',
                    hidden: me.first,
                    handler: function () {
                        var p = this.up('sample_balloon_panel');
                        p.fireEvent('prev', p);
                    },
                    scope: 'this'
                },
                {
                    text: 'Next',
                    hidden: me.last,
                    handler: function () {
                        var p = this.up('sample_balloon_panel');
                        p.fireEvent('next', p);
                    },
                    scope: 'this'
                },
                {
                    text: 'End',
                    hidden: !me.last,
                    handler: function () {
                        var p = this.up('sample_balloon_panel');
                        p.fireEvent('end', p);
                    },
                    scope: 'this'
                }
            ]
        });

        me.callParent(arguments);
    }
});
@charset "UTF-8";

.sample-balloon-panel {
  background-color: #fff;

  .#{$prefix}panel-body {
    color: #666;
    font-size: 16px;
    line-height: 1.2;
  }
}

任意のコンフィグを双方向バインディングに対応する

ビュー作成時に独自のコンフィグを追加することがあります。

ここでは追加したコンフィグをデータバインディングしてみます。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    config: {
        text: null
    },

    applyText: function (text) {
        var me = this;

        me.setHtml(Ext.String.htmlEncode(text));

        return text;
    }
});

まずはtextコンフィグを用意しました。

textコンフィグに値を設定すると、HTMLエンコードして画面に表示するようにしています。

Ext.define('Sample.view.sample.PanelModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.sample_panel',

    data: {
        text: '<b>Hello, World!!</b>'
    }
});

textという値を持ったビューモデルを用意しました。

これをビューにデータバインディングします。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    viewModel: 'sample_panel',

    config: {
        text: null
    },

    bind: {
        text: '{text}'
    },

    applyText: function (text) {
        var me = this;

        me.setHtml(Ext.String.htmlEncode(text));

        return text;
    }
});

これでビューモデル => ビューの一方向のデータバインディングとなります。

ビュー => ビューモデルについては、publishesコンフィグを指定する必要があります。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    viewModel: 'sample_panel',

    config: {
        text: null
    },

    publishes: {
        text: true
    },

    bind: {
        text: '{text}'
    },

    applyText: function (text) {
        var me = this;

        me.setHtml(Ext.String.htmlEncode(text));

        return text;
    }
});

これで、ビュー側でtextの値が変更されるとビューモデルの値に反映されるようになります。

ツリーパネル[modern]

今回はmodernのツリーパネルです。

できることはclassicのツリーパネルとほぼ同じです。

基本的な使い方

モデルとストアはclassicのときと同じものを使っています。

ビューにはExt.grid.TreeとExt.list.Treeを使えます。

ビュー

まずはExt.grid.Treeを使った場合です。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.grid.Tree',
    xtype: 'sample_panel',

    store: 'Earnings',

    rootVisible: false
});

f:id:sham-memo:20170819135654p:plain

次にExt.list.Treeを使った場合です。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.list.Tree',
    xtype: 'sample_panel',

    store: 'Earnings',

    ui: 'nav',

    expanderFirst: false
});

f:id:sham-memo:20170819144220p:plain

Ext.list.Treeはuiが少し用意されてるみたいです。expanderFirstコンフィグはExt.grid.Treeでも使えます。

Ext.list.TreeはExt.Gadgetを継承しているみたいで、Ext.grid.Treeとは根本的に違うようです。

編集などの機能はExt.grid.Treeでしか実装できません。

スマフォサイズで単純なツリーパネルを使う場合、expanderFirst: falseのほうが個人的に好きなUIです。

ツリーグリッド

最後に、ツリーグリッドにしてさらに編集可能にしてみました。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.grid.Tree',
    xtype: 'sample_panel',

    requires: [
        'Ext.grid.column.Tree',
        'Ext.grid.column.Number',
        'Ext.grid.plugin.CellEditing'
    ],

    store: 'Earnings',

    rootVisible: false,

    rowLines: true,
    columnLines: true,

    columns: [
        {
            text: 'グループ名',
            xtype: 'treecolumn',
            width: 300,
            flex: 1,
            dataIndex: 'text',
            editable: true
        },
        {
            text: '第1四半期',
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter1',
            align: 'right',
            editable: true
        },
        {
            text: '第2四半期',
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter2',
            align: 'right',
            editable: true
        },
        {
            text: '第3四半期',
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter3',
            align: 'right',
            editable: true
        },
        {
            text: '第4四半期',
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter4',
            align: 'right',
            editable: true
        }
    ],

    plugins: {
        gridcellediting: true
    }
});

f:id:sham-memo:20170819153427p:plain

ドラッグ&ドロップで入れ替えてみようと思いましたが、API見ても分かりませんでした。

知ってる人がいたらぜひ教えてください( )

ツリーパネル[classic] (2)

今回はツリーグリッドを取り上げます。

基本的な使い方

Ext.tree.Panelクラスを使うと、ツリーパネルとグリッドパネルを一緒にしたようなツリーグリッドUIを利用できます。

基本的な使い方はツリーパネルと同じで、それに加えてExt.tree.Panelクラスのcolumnsコンフィグを指定するだけです。

columnsコンフィグは、ほぼExt.grid.Panelのcolumnsコンフィグと同じです。

ストアとモデルは前回と同じものを使用しました

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.tree.Panel',
    xtype: 'sample_panel',

    requires: [
        'Ext.tree.Column',
        'Ext.grid.column.Number'
    ],

    title: 'ツリーグリッド サンプル',

    store: 'Earnings',

    columnLines: true,
    rowLines: true,
    rootVisible: false,

    columns: [
        {
            xtype: 'treecolumn',
            text: 'グループ',
            dataIndex: 'text',
            flex: 1
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter1',
            text: '第1四半期',
            width: 120,
            align: 'right'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter2',
            text: '第2四半期',
            width: 120,
            align: 'right'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter3',
            text: '第3四半期',
            width: 120,
            align: 'right'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter4',
            text: '第4四半期',
            width: 120,
            align: 'right'
        }
    ]
});

f:id:sham-memo:20170807231412p:plain

編集

編集には、グリッドパネルと同じプラグインを使えます。

例えば、セル編集であれば下記のようになります。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.tree.Panel',
    xtype: 'sample_panel',

    requires: [
        'Ext.tree.Column',
        'Ext.grid.column.Number',
        'Ext.grid.plugin.CellEditing'
    ],

    title: 'ツリーグリッド サンプル',

    store: 'Earnings',

    columnLines: true,
    rowLines: true,
    rootVisible: false,

    columns: [
        {
            xtype: 'treecolumn',
            text: 'グループ',
            dataIndex: 'text',
            flex: 1,
            editor: 'textfield'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter1',
            text: '第1四半期',
            width: 120,
            align: 'right',
            editor: 'numberfield'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter2',
            text: '第2四半期',
            width: 120,
            align: 'right',
            editor: 'numberfield'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter3',
            text: '第3四半期',
            width: 120,
            align: 'right',
            editor: 'numberfield'
        },
        {
            xtype: 'numbercolumn',
            format: '0,000',
            dataIndex: 'quarter4',
            text: '第4四半期',
            width: 120,
            align: 'right',
            editor: 'numberfield'
        }
    ],

    plugins: {
        cellediting: true
    }
});

f:id:sham-memo:20170807233505p:plain

 

 

ツリーパネルはちょっとクセがあって使いづらいですが、ツリーグリッドのUIは表現力が高いので、いろいろな場面で使いたくなりますね。

ツリーパネル[classic] (1)

今回はclassicのツリーパネルを取り上げます。

ツリーパネルでは、エクスプローラディレクトリやファイルの位置関係を階層構造で表現するUIを提供できるビューです。

modernにも同じようなビューがありますが、classicとはクラス名が異なるため今回はclassicのみ対象とします(次はmodernを取り上げる予定)。

基本的な使い方

モデル、ストア

モデルにはExt.data.TreeModelクラス、ストアにはExt.data.TreeStoreクラスを使います。

Ext.define('Sample.model.Earning', {
    extend: 'Ext.data.TreeModel',

    fields: [
        {
            name: 'text',
            type: 'string'
        },
        {
            name: 'quarter1',
            type: 'int'
        },
        {
            name: 'quarter2',
            type: 'int'
        },
        {
            name: 'quarter3',
            type: 'int'
        },
        {
            name: 'quarter4',
            type: 'int'
        }
    ]
});

Ext.define('Sample.store.Earnings', {
    extend: 'Ext.data.TreeStore',

    requires: [
        'Ext.data.proxy.Memory'
    ],

    model: 'Sample.model.Earning',

    proxy: {
        type: 'memory'
    },

    root: {
        text: '',
        children: [
            {
                id: 1,
                text: '全体',
                quarter1: 20000,
                quarter2: 11100,
                quarter3: 5200,
                quarter4: 13600,
                expanded: true,
                iconCls: 'x-fa fa-building',
                children: [
                    {
                        id: 2,
                        text: 'グループ1',
                        quarter1: 1000,
                        quarter2: 800,
                        quarter3: 500,
                        quarter4: 1200,
                        iconCls: 'x-fa fa-users',
                        leaf: true
                    },
                    {
                        id: 3,
                        text: 'グループ2',
                        quarter1: 5000,
                        quarter2: 2300,
                        quarter3: 1200,
                        quarter4: 3000,
                        iconCls: 'x-fa fa-users',
                        children: [
                            {
                                id: 4,
                                text: 'グループ2-1',
                                quarter1: 3000,
                                quarter2: 1500,
                                quarter3: 700,
                                quarter4: 1100,
                                iconCls: 'x-fa fa-users',
                                leaf: true
                            },
                            {
                                id: 5,
                                text: 'グループ2-2',
                                quarter1: 2000,
                                quarter2: 800,
                                quarter3: 500,
                                quarter4: 1900,
                                iconCls: 'x-fa fa-users',
                                leaf: true
                            }
                        ]
                    },
                    {
                        id: 6,
                        text: 'グループ3',
                        quarter1: 14000,
                        quarter2: 8000,
                        quarter3: 3500,
                        quarter4: 9400,
                        expanded: true,
                        iconCls: 'x-fa fa-users',
                        children: [
                            {
                                id: 71,
                                text: 'グループ3-1',
                                quarter1: 1000,
                                quarter2: 2000,
                                quarter3: 500,
                                quarter4: 1100,
                                iconCls: 'x-fa fa-users',
                                leaf: true
                            },
                            {
                                id: 8,
                                text: 'グループ3-2',
                                quarter1: 5000,
                                quarter2: 2000,
                                quarter3: 2300,
                                quarter4: 500,
                                iconCls: 'x-fa fa-users',
                                leaf: true
                            },
                            {
                                id: 9,
                                text: 'グループ3-3',
                                quarter1: 8000,
                                quarter2: 4000,
                                quarter3: 700,
                                quarter4: 7800,
                                iconCls: 'x-fa fa-users',
                                leaf: true
                            }
                        ]
                    }
                ]
            }
        ]
    }
});

上記ではストアのデータをインメモリにしていますが、ツリーストア用のデータ構造にする必要がある点に注意してください。

まず、最上位の位置にルートノードが必要です。

さらにその直下に所属すべきノードをchildrenプロパティで並べていきます。末端のノードにはleaf: trueを指定しましょう。

ノードのtextプロパティは、ビューに表示されるテキストです。(これtextから変更できるのかな?捜したけど見つからなかったです)

idプロパティは内部的に使っているみたいで、モデルのフィールドに定義したらエラーとなりました。何かIDとして付けておきたい場合は、別のフィールド名で用意しておくようにしましょう。

他にも使えるプロパティがありますが、詳細は下記リンクからドキュメントを参照してください。Ext.data.NodeInterfaceのコンフィグがほとんどそのまま指定できます。

http://docs.sencha.com/extjs/6.5.1/classic/Ext.data.NodeInterface.html

http://docs.sencha.com/extjs/6.5.1/classic/Ext.data.TreeStore.html#cfg-root

ビュー

あとはExt.tree.Panelでビューを作成し、storeに先ほど作成したストアを指定します。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.tree.Panel',
    xtype: 'sample_panel',

    title: 'ツリーパネル サンプル',

    store: 'Earnings',

    rootVisible: false

});

ルートノードは必ず1つなので、表現したいデータの最上位ノートが複数の場合は、rootVisible: falseとすることでルートノードを表示しないように制御できます。

f:id:sham-memo:20170730111121p:plain

mtype, childTypeでノードごとにモデルを切り替えられる

ツリーストアの場合、ノードごとにモデルを指定できます(使うべき場面を思いつかなかった。。mtype, childTypeを知らなかったのでとりあえず書いときました)

http://docs.sencha.com/extjs/6.5.1/classic/Ext.tree.Panel.html

ロード

サーバにリクエストを投げる場合、まずは通常のストアのようにプロキシを設定します。

Ext.define('Sample.store.Earnings', {
    extend: 'Ext.data.TreeStore',

    model: 'Sample.model.Earning',

    proxy: {
        type: 'ajax',
        url: 'dummy/earnings.json'
    }
});

これでloadメソッドが呼ばれると、下記のようにnodeというリクエストパラメータ付きでリクエストされます。

f:id:sham-memo:20170730114847p:plain

これはTreeStoreのnodeParamとdefaultRootIdで変更できます。

Ext.define('Sample.store.Earnings', {
    extend: 'Ext.data.TreeStore',

    model: 'Sample.model.Earning',

    proxy: {
        type: 'ajax',
        url: 'dummy/earnings.json'
    },

    nodeParam: 'id',

    defaultRootId: 1
});

こうすると、id: 1でリクエストされます。(ちなみに0はダメみたいです。なら'root'のままでサーバ側で判定しても良いのかもね)

ルートノードより下位のノードでは、各ノードのidプロパティの値を使って展開時にリクエストされるようになっています。

一度展開したノードは、一旦閉じてから再度展開してもロードしないようです。

その場合もロードさせたい場合は、下記のように無理やりloadedをfalseにすればロードしてくれました。もっと良い方法を知っていれば誰か教えてください。

Ext.define('Sample.store.Earnings', {
    extend: 'Ext.data.TreeStore',

    model: 'Sample.model.Earning',

    proxy: {
        type: 'ajax',
        url: 'dummy/earnings.json'
    },

    nodeParam: 'id',

    defaultRootId: 1,

    listeners: {
        nodecollapse: function (node) {
            node.set('loaded', false);
        }
    }
});

ちなみに、サーバからは下記のようなレスポンスを返します。

{
  "text": "Root",
  "children": [
    {
      "text": "全体",
      "quarter1": 20000,
      "quarter2": 11100,
      "quarter3": 5200,
      "quarter4": 13600,
      "expanded": false,
      "iconCls": "x-fa fa-building"
    }
  ]
}

追加、削除

ノードの追加、削除はモデルに対して行います。

Ext.data.TreeModelはExt.data.NodeInterfaceの機能を使えるようになっているので、appendChild、removeメソッドを呼ぶことができます。

listeners: {
    selectionchange: function (view, records) {
        var record = records[0];

        if (record) {
            record.appendChild({
                id: 100,
                text: 'hogehoge',
                leaf: true
            });
        }
    }
}

listeners: {
    selectionchange: function (view, records) {
        var record = records[0];

        if (record) {
            record.remove(true);
        }
    }
}

selectionchangeのタイミングで追加、削除というかなり雑なコードになっていますが、モデルに対してこのように記述すると、対象ノードの子ノードとして追加したり削除したりできます。

ドラッグ&ドロップ

Ext.tree.plugin.TreeViewDragDropプラグインを使います。

 Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.tree.Panel',
    xtype: 'sample_panel',

    requires: [
        'Ext.tree.plugin.TreeViewDragDrop'
    ],

    title: 'ツリーパネル サンプル',

    viewConfig: {
        plugins: {
            treeviewdragdrop: true
        }
    },

    store: 'Earnings',

    rootVisible: false
});

そのうえで、ノードのallowDrag、allowDropでドラッグ、ドロップ可否を制御できます。

ドロップ時にbeforedrop、dropイベントが発生するので、処理はそのタイミングで実行します。

レスポンシブ対応

今回はレスポンシブ対応を試してみます。

CSSではメディアクエリを使ってレスポンシブ対応しますが、ExtJSではExt.plugin.Responsiveクラスを使いjavascript上で処理します。

ExtJSのコードをざっと見た感じでは、resizeイベント発火を起点にしているようです。

基本的な実装方法

Ext.plugin.Responsiveプラグインを使用するようにして、responsiveConfigコンフィグで状態に応じた設定を指定します。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    requires: [
        'Ext.plugin.Responsive'
    ],

    cls: 'sample-panel',

    plugins: 'responsive',

    responsiveConfig: {
        'width < 800': {
            html: '幅が800px未満の場合のテキストを表示しています'
        },

        'width >= 800': {
            html: '幅が800px以上の場合のテキストを表示しています'
        }
    }
});

f:id:sham-memo:20170717120206p:plain

f:id:sham-memo:20170717120040p:plain

それぞれブラウザの幅が800px未満、800px以上の場合の表示です。

responsiveConfigコンフィグのキーとして、width < 800、width >= 800のように指定することで、対象コンポーネントのwidthが800px未満と800px以上という条件を指定できます。

さらにそれぞれの場合にどのような値を設定するかを、条件の値としてオブジェクトリテラルで指定できます。

responsiveConfigコンフィグの条件としては、下記の値が指定できます(http://docs.sencha.com/extjs/6.5.0/classic/Ext.mixin.Responsive.html#cfg-responsiveConfig より引用)。

landscape - True if the device orientation is landscape (always true on desktop devices).
portrait - True if the device orientation is portrait (always false on desktop devices).
tall - True if width < height regardless of device type.
wide - True if width > height regardless of device type.
width - The width of the viewport in pixels.
height - The height of the viewport in pixels.
platform - An object containing various booleans describing the platform (see Ext.platformTags). The properties of this object are also available implicitly (without "platform." prefix) but this sub-object may be useful to resolve ambiguity (for example, if one of the responsiveFormulas overlaps and hides any of these properties). Previous to Ext JS 5.1, the platformTags were only available using this prefix.

少し実用的な使い方

少し実用的な使い方だとこんな風にもできます。

ナビゲーションメニューの位置を、幅によって切り替えることを想定しています。

Ext.define('Sample.view.main.Main', {
    extend: 'Ext.panel.Panel',
    xtype: 'app-main',

    requires: [
        'Ext.plugin.Viewport',
        'Ext.plugin.Responsive',
        'Ext.layout.container.Border'
    ],

    layout: 'border',

    defaults: {
        xtype: 'panel'
    },

    items: [
        {
            xtype: 'panel',

            cls: 'menu-panel',

            plugins: 'responsive',

            responsiveConfig: {
                'width < 800': {
                    region: 'north',
                    width: '100%',
                    html: 'region: northのパネル'
                },
                'width >= 800': {
                    region: 'west',
                    width: 250,
                    html: 'region: westのパネル'
                }
            }
        },
        {
            region: 'center',
            cls: 'center-panel',
            html: 'region: centerのパネル'
        }
    ]
});

f:id:sham-memo:20170717124412p:plain

f:id:sham-memo:20170717124422p:plain

注意点

setメソッドがない場合は自分で定義する必要がある

responsiveConfigコンフィグに指定する値として、気を付けなければならない点があります。

レスポンシブの内部処理は、指定された設定値のキーに対してsetメソッドを呼び出すようになっています。

そのためsetメソッドが実装されていないコンフィグを指定した場合は、値が切り替わりません。

例えばclsコンフィグを切り替えようと、下記のようにしても反映されません。(以前はエラーになっていましたが無視されて処理継続されるようです)

responsiveConfig: {
    'width < 800': {
        region: 'north',
        cls: 'north-menu-panel',
        width: '100%'
    },
    'width >= 800': {
        region: 'west',
        cls: 'west-menu-panel',
        width: 250
    }
}

このような場合は、自分でsetメソッドを用意してあげる必要があります。

Ext.define('Sample.view.sample.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'sample_panel',

    config: {
        /**
         * @cfg {string} 現在のレスポンシブによるセレクタ名。
         */
        currentResponsiveCls: null
    },

    /**
     * clsコンフィグのsetメソッド(レスポンシブプラグインで呼び出される)。
     * @param {string} cls セレクタ名
     */
    setCls: function (cls) {
        var me = this,
            currentCls = me.getCurrentResponsiveCls();

        if (currentCls) {
            me.removeCls(currentCls);
        }

        if (cls) {
            me.setCurrentResponsiveCls(cls);
            me.addCls(cls);
        }
    }
});

Ext.define('Sample.view.main.Main', {
    extend: 'Ext.panel.Panel',
    xtype: 'app-main',

    requires: [
        'Ext.plugin.Viewport',
        'Ext.plugin.Responsive',
        'Ext.layout.container.Border'
    ],

    layout: 'border',

    defaults: {
        xtype: 'panel'
    },

    items: [
        {
            xtype: 'sample_panel',

            plugins: 'responsive',

            html: 'ナビゲーションメニューパネル',

            responsiveConfig: {
                'width < 800': {
                    region: 'north',
                    cls: 'north-menu-panel',
                    width: '100%'
                },
                'width >= 800': {
                    region: 'west',
                    cls: 'west-menu-panel',
                    width: 250
                }
            }
        },
        {
            region: 'center',
            cls: 'content-panel',
            html: 'コンテンツパネル'
        }
    ]
});
@charset "UTF-8";

.west-menu-panel {
    .x-panel-body-default {
        background-color: #90caf9;
    }
}

.north-menu-panel {
    .x-panel-body-default {
        background-color: #80cbc4;
    }
}

.content-panel {
    .x-panel-body-default {
        background-color: #e3f2fd;
    }
}

f:id:sham-memo:20170717132744p:plain

f:id:sham-memo:20170717132755p:plain

あまりスマートな記述ではありませんが、一応期待通りになりました。

aこういうsetメソッドはプラグインとして作成したほうが良いでしょうね。

modernのプラグインの動作があやしい

バグかもしれませんが、modernのExt.plugin.Responsiveの動作が若干怪しいです。

メインビューで直接レスポンシブを指定する場合は動作しましたが、ビューをクラス定義してその中でレスポンシブ指定するとなぜか動作しませんでした。

ExtJS6.5.1に期待します。