初心者のためのExtJS入門

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

アイコンフォント

ExtJS6.5では、FontAwesomeとPictosがパッケージに含まれています。

今回はそれらの使い方を取り上げます。

まずはapp.jsonでアイコンフォントのパッケージをrequiresに追記します。

"requires": [
  "font-awesome",
  "font-pictos"
]

あとはclass属性に使用したいフォントのセレクタ名を正しく指定するだけです。

例えばHTMLタグであればiタグに、ボタンのアイコンならiconClsに指定します。

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

    cls: 'sample-panel',

    padding: 50,
    bodyPadding: '15 0 0 0',

    tbar: {
        items: [
            {
                xtype: 'button',
                tooltip: '編集',
                iconCls: 'pictos pictos-pencil',
                scale: 'large'
            },
            {
                xtype: 'button',
                tooltip: '編集',
                iconCls: 'x-fa fa-pencil',
                scale: 'large'
            }
        ]
    },

    html: [
        '<i class="pictos pictos-pencil"></i>',
        '<i class="x-fa fa-pencil"></i>'
    ]
});

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

同じようなアイコンでもフォントで微妙に違いがでますね。

FontAwesomeの場合はx-fa fa-xxxx、Pictosの場合はpictos pictos-xxxxという形式になります。

FontAwesomeの場合は、http://fontawesome.io/icons/ を見るのが良いと思います。気を付けないといけないのは、ExtJSパッケージのフォントのバージョンが古い場合があるということです。

Pictosはいまいち一覧でセレクタ名までわかるページが無いんですよね。http://pictos.cc/classic/font を参照しつつ、セレクタ名は ext/packages/font-pictos/all.scss を直接みるのが早いかも。

 

 

 

ところでclassicの場合、ボタンではglyphコンフィグというものを使うこともできます(ExtJS5では結構使いました)。

が、modernには無いこととiconClsで同じUIで表示できることから今はほとんど使う必要がなくなっています。

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"
    }
  ]
}

※ ツリー用のモデルとストアの両方にproxyを指定したときに、expanded: trueのノードが無限ロードするという現象が発生したので、ご注意を。

追加、削除

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

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イベントが発生するので、処理はそのタイミングで実行します。