初心者のためのExtJS入門

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

グリッドの機能:編集[modern]

今回はmodernのグリッド編集を試しました。

とりあえず編集できるようにする

Ext.grid.plugin.Editableが用意されていたので、まずは1カラムだけ編集するようにしてみました。

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Grid
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Grid',
    xtype: 'main_list',

    requires: [
        'Ext.grid.column.Number',
        'Ext.grid.column.Check',
        'Ext.grid.column.Date',
        'Ext.grid.plugin.Editable'
    ],

    title: '商品一覧',

    store: 'Item',

    plugins: 'grideditable',

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'name',
            text: '商品名',
            minWidth: 300,
            flex: 1,
            editable: true
        },
        {
            xtype: 'numbercolumn',
            format:'0,000円',
            dataIndex: 'price',
            text: '金額',
            align: 'right'
        },
        {
            xtype: 'checkcolumn',
            dataIndex: 'isActive',
            text: '有効状況',
            sortable: false
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i',
            dataIndex: 'created',
            text: '登録日時',
            width: 180
        }
    ]
});

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

行をダブルタップすると、↑のように編集用のシートが画面右側からスライドインします。

editable: trueとしたカラムだけ、入力フィールドが用意されるようです。

もう少し細かく設定する

もう少し編集シートをカスタマイズしてみました。

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Grid
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Grid',
    xtype: 'main_list',

    requires: [
        'Ext.grid.column.Number',
        'Ext.grid.column.Boolean',
        'Ext.grid.column.Date',
        'Ext.grid.plugin.Editable'
    ],

    title: '商品一覧',

    store: 'Item',

    plugins: {
        type: 'grideditable',

        defaultFormConfig: {
            xtype: 'formpanel',
            scrollable: true
        },

        formConfig: {
            items: [
                {
                    xtype: 'textfield',
                    name: 'name',
                    label: '商品名'
                },
                {
                    xtype: 'numberfield',
                    name: 'price',
                    label: '金額(円)'
                },
                {
                    xtype: 'togglefield',
                    name: 'isActive',
                    label: '有効状況'
                }
            ]
        },

        toolbarConfig: {
            xtype: 'titlebar',
            docked: 'top',
            items: [
                {
                    xtype: 'button',
                    text: 'キャンセル',
                    align: 'left',
                    action: 'cancel'
                },
                {
                    xtype: 'button',
                    text: '更新',
                    align: 'right',
                    action: 'submit'
                }
            ]
        }
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'name',
            text: '商品名',
            minWidth: 300,
            flex: 1
        },
        {
            xtype: 'numbercolumn',
            format:'0,000円',
            dataIndex: 'price',
            text: '金額',
            align: 'right'
        },
        {
            xtype: 'booleancolumn',
            dataIndex: 'isActive',
            text: '有効状況',
            trueText: '有効',
            falseText: '無効',
            align: 'center'
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i',
            dataIndex: 'created',
            text: '登録日時',
            width: 180
        }
    ]
});

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

formConfigのitemsコンフィグに入力フィールドを定義することもできるようで、このようにすると、カラムのeditableは不要になるみたいです。

ボタンのテキストを日本語にしてみようと思ったのですが、Ext.grid.plugin.Editableの実装がいけていなくて、DELETEボタンだけは簡単に変更できないようです。

コードを見ると↓のようになっており、textコンフィグに固定文字列が指定されています。

if (me.getEnableDeleteButton()) {
    form.add({
        xtype: 'button',
        text: 'Delete',
        ui: 'decline',
        margin: 10,
        handler: function() {
            grid.getStore().remove(record);
            sheet.hide();
        }
    });
}

オーバーライドクラスで対応

「できません」で終わるのも嫌なので、オーバーライドクラスを作って、その中で対応します。

/**
 * グリッド編集用プラグインクラス。
 *
 * @class Sample.overrides.grid.plugin.Editable
 * @extend Ext.grid.plugin.Editable
 */
Ext.define('Sample.overrides.grid.plugin.Editable', {
    override: 'Ext.grid.plugin.Editable',

    config: {
        /**
         * @cfg {String} 削除ボタンテキスト。
         */
        deleteButtonText: 'DELETE'
    },

    // @override
    onTrigger: function(e) {
        var me = this,
            grid = me.getGrid(),
            formConfig = me.getFormConfig(),
            toolbarConfig = me.getToolbarConfig(),
            record = me.getRecordByTriggerEvent(e),
            fields, form, sheet, toolbar;

        if (record) {
            if (formConfig) {
                this.form = form = Ext.factory(formConfig, Ext.form.Panel);
            } else {
                this.form = form = Ext.factory(me.getDefaultFormConfig());

                fields = me.getEditorFields(grid.getColumns());
                form.down('fieldset').setItems(fields);
            }

            form.setRecord(record);

            toolbar = Ext.factory(toolbarConfig, Ext.form.TitleBar);
            toolbar.down('button[action=cancel]').on('tap', 'onCancelTap', this);
            toolbar.down('button[action=submit]').on('tap', 'onSubmitTap', this);

            this.sheet = sheet = grid.add({
                xtype: 'sheet',
                items: [toolbar, form],
                hideOnMaskTap: true,
                enter: 'right',
                exit: 'right',
                centered: false,
                right: 0,
                width: 320,
                layout: 'fit',
                stretchY: true,
                hidden: true
            });

            if (me.getEnableDeleteButton()) {
                form.add({
                    xtype: 'button',
                    text: me.getDeleteButtonText(),
                    ui: 'decline',
                    margin: 10,
                    handler: function() {
                        grid.getStore().remove(record);
                        sheet.hide();
                    }
                });
            }

            sheet.on('hide', 'onSheetHide', this);

            sheet.show();
        }
    }
});

これで↓のようにdeleteButtonTextを設定します。

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Grid
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Grid',
    xtype: 'main_list',

    requires: [
        'Ext.grid.column.Number',
        'Ext.grid.column.Boolean',
        'Ext.grid.column.Date',
        'Ext.grid.plugin.Editable'
    ],

    title: '商品一覧',

    store: 'Item',

    plugins: {
        type: 'grideditable',

        deleteButtonText: '削除',

        defaultFormConfig: {
            xtype: 'formpanel',
            scrollable: true
        },

        formConfig: {
            items: [
                {
                    xtype: 'textfield',
                    name: 'name',
                    label: '商品名'
                },
                {
                    xtype: 'numberfield',
                    name: 'price',
                    label: '金額(円)'
                },
                {
                    xtype: 'togglefield',
                    name: 'isActive',
                    label: '有効状況'
                }
            ]
        },

        toolbarConfig: {
            xtype: 'titlebar',
            docked: 'top',
            items: [
                {
                    xtype: 'button',
                    text: 'キャンセル',
                    align: 'left',
                    action: 'cancel'
                },
                {
                    xtype: 'button',
                    text: '更新',
                    align: 'right',
                    action: 'submit'
                }
            ]
        }
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'name',
            text: '商品名',
            minWidth: 300,
            flex: 1
        },
        {
            xtype: 'numbercolumn',
            format:'0,000円',
            dataIndex: 'price',
            text: '金額',
            align: 'right'
        },
        {
            xtype: 'booleancolumn',
            dataIndex: 'isActive',
            text: '有効状況',
            trueText: '有効',
            falseText: '無効',
            align: 'center'
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i',
            dataIndex: 'created',
            text: '登録日時',
            width: 180
        }
    ]
});

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

こういう対応はExtJSあるあるです。

グリッドの機能:カラム(列)[modern]

今回はmodernのグリッドを試してみます。

modernのグリッドはExt.grid.Gridクラス(http://docs.sencha.com/extjs/6.2.1/modern/Ext.grid.Grid.html)です。classicの場合とクラス名が異なるみたいです。あと、パネルクラスを継承していないので注意が必要です。

モデル・ストア

流用して↓のようにしました。

/**
 * 商品モデルクラス。
 *
 * @class Sample.model.Item
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Item', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'name',
            type: 'string'
        },
        {
            name: 'price',
            type: 'int'
        },
        {
            name: 'isActive',
            type: 'boolean',
            defaultValue: false
        },
        {
            name: 'created',
            type: 'date'
        }
    ]
});

/**
 * 商品ストアクラス。
 *
 * @class Sample.store.Item
 * @extend Ext.data.Store
 */
Ext.define('Sample.store.Item', {
    extend: 'Ext.data.Store',
    alias: 'store.item',

    requires: [
        'Ext.data.proxy.Memory',
        'Sample.model.Item'
    ],

    model: 'Sample.model.Item',

    proxy: {
        type: 'memory'
    },

    data: [
        { id: 1, name: '商品1', price: 1000, isActive: true, created: new Date() },
        { id: 2, name: '商品2', created: new Date() },
        { id: 3, name: '商品3', created: new Date() },
        { id: 4, name: '商品4', created: new Date() },
        { id: 5, name: '商品5', created: new Date() },
        { id: 6, name: '商品6', created: new Date() },
        { id: 7, name: '商品7', created: new Date() },
        { id: 8, name: '商品8', created: new Date() },
        { id: 9, name: '商品9', created: new Date() },
        { id: 10, name: '商品10', created: new Date() },
        { id: 11, name: '商品11', created: new Date() },
        { id: 12, name: '商品12', created: new Date() },
        { id: 13, name: '商品13', created: new Date() },
        { id: 14, name: '商品14', created: new Date() },
        { id: 15, name: '商品15', created: new Date() },
        { id: 16, name: '商品16', created: new Date() },
        { id: 17, name: '商品17', created: new Date() },
        { id: 18, name: '商品18', created: new Date() },
        { id: 19, name: '商品19', created: new Date() },
        { id: 20, name: '商品20', created: new Date() }
    ]
});

ビュー

今回はNumber、Check、Dateの3つのカラムクラスを使っています。

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Grid
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Grid',
    xtype: 'main_list',

    requires: [
        'Ext.grid.column.Number',
        'Ext.grid.column.Check',
        'Ext.grid.column.Date'
    ],

    title: '商品一覧',

    store: 'Item',

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'name',
            text: '商品名',
            minWidth: 300,
            flex: 1
        },
        {
            xtype: 'numbercolumn',
            format:'0,000円',
            dataIndex: 'price',
            text: '金額',
            align: 'right'
        },
        {
            xtype: 'checkcolumn',
            dataIndex: 'isActive',
            text: '有効状況',
            sortable: false
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i',
            dataIndex: 'created',
            text: '登録日時',
            width: 180
        }
    ]
});

/**
 * メインパネル。
 *
 * @class Sample.view.main.Panel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.Panel', {
    extend: 'Ext.Panel',
    xtype: 'main_panel',

    requires: [
        'Sample.view.main.List'
    ],

    layout: 'fit',

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

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

↑はiPadのサイズで表示しています。スマフォのような小さい画面サイズの場合は横スクロールが発生しました。

基本的なグリッドの使い方はclassicと同じです。カラムクラスは少し数が少ないですが、モバイル表示の場合はclassicほど要求が少ないのかもしれません。

個人的な感想ですが、横スクロールが発生するので、実際のアプリケーションに適用するのは難易度高そうです。ただし、それはスマフォの場合だけでタブレットの場合は横スクロールぐらい気にならないと思うので、タブレット向けには使っていけそうです。スマフォかタブレットかで、ビューを切り替えるような実装もありかもしれませんね。

グリッドの機能:ページング[classic]

Ext.toolbar.Pagingを使って、グリッドをページング対応してみます。

まずは普通のグリッドを作成

↓のようなモデルとストアを作成しました。(今はストアにautoLoad: trueを設定してます)

/**
 * 商品モデルクラス。
 *
 * @class Sample.model.Item
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Item', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'name',
            type: 'string'
        },
        {
            name: 'created',
            type: 'date'
        }
    ]
});

/**
 * 商品ストアクラス。
 *
 * @class Sample.store.Item
 * @extend Ext.data.Store
 */
Ext.define('Sample.store.Item', {
    extend: 'Ext.data.Store',
    alias: 'store.item',

    requires: [
        'Ext.data.proxy.Memory',
        'Sample.model.Item'
    ],

    model: 'Sample.model.Item',

    proxy: {
        type: 'memory'
    },

    data: [
        { id: 1, name: '商品1', created: new Date() },
        { id: 2, name: '商品2', created: new Date() },
        { id: 3, name: '商品3', created: new Date() },
        { id: 4, name: '商品4', created: new Date() },
        { id: 5, name: '商品5', created: new Date() },
        { id: 6, name: '商品6', created: new Date() },
        { id: 7, name: '商品7', created: new Date() },
        { id: 8, name: '商品8', created: new Date() },
        { id: 9, name: '商品9', created: new Date() },
        { id: 10, name: '商品10', created: new Date() },
        { id: 11, name: '商品11', created: new Date() },
        { id: 12, name: '商品12', created: new Date() },
        { id: 13, name: '商品13', created: new Date() },
        { id: 14, name: '商品14', created: new Date() },
        { id: 15, name: '商品15', created: new Date() },
        { id: 16, name: '商品16', created: new Date() },
        { id: 17, name: '商品17', created: new Date() },
        { id: 18, name: '商品18', created: new Date() },
        { id: 19, name: '商品19', created: new Date() },
        { id: 20, name: '商品20', created: new Date() }
    ]
});

グリッドは↓です。

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Panel
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Panel',
    xtype: 'main_list',

    title: '商品一覧',

    store: 'Item',

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right',
            width: 100
        },
        {
            dataIndex: 'name',
            text: '商品名',
            flex: 1
        }
    ]
});

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

結果は↑のようになりました。トリミングしていますが、商品20まで表示されています。

ページングツールバー(Ext.toolbar.Paging)を設置する

/**
 * 商品一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Panel
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Panel',
    xtype: 'main_list',

    requires: [
        'Ext.toolbar.Paging'
    ],

    title: '商品一覧',

    store: 'Item',

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right',
            width: 100
        },
        {
            dataIndex: 'name',
            text: '商品名',
            flex: 1
        }
    ],

    dockedItems: [
        {
            dock: 'bottom',
            xtype: 'pagingtoolbar',
            store: 'Item'
        }
    ]

});

dockedItemsコンフィグにページングツールバーを設置しています。

dockedItemsコンフィグは、パネルの一番外側(上下左右)にビューを設置したいときに使います。今回の場合、dock: 'bottom'として下に設置しました。

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

プロキシに合わせて対応する

ページングツールバーは設置できましたが、このままでは不十分です。

ストアのプロキシに応じて、もう少し対応が必要になります。

今回はExt.data.proxy.Memoryを使っているので、ストアクラスを変更します。

/**
 * 商品ストアクラス。
 *
 * @class Sample.store.Item
 * @extend Ext.data.Store
 */
Ext.define('Sample.store.Item', {
    extend: 'Ext.data.Store',
    alias: 'store.item',

    requires: [
        'Ext.data.proxy.Memory',
        'Sample.model.Item'
    ],

    model: 'Sample.model.Item',
    
    pageSize: 5,

    proxy: {
        type: 'memory',
        enablePaging: true
    },

    data: [
        { id: 1, name: '商品1', created: new Date() },
        { id: 2, name: '商品2', created: new Date() },
        { id: 3, name: '商品3', created: new Date() },
        { id: 4, name: '商品4', created: new Date() },
        { id: 5, name: '商品5', created: new Date() },
        { id: 6, name: '商品6', created: new Date() },
        { id: 7, name: '商品7', created: new Date() },
        { id: 8, name: '商品8', created: new Date() },
        { id: 9, name: '商品9', created: new Date() },
        { id: 10, name: '商品10', created: new Date() },
        { id: 11, name: '商品11', created: new Date() },
        { id: 12, name: '商品12', created: new Date() },
        { id: 13, name: '商品13', created: new Date() },
        { id: 14, name: '商品14', created: new Date() },
        { id: 15, name: '商品15', created: new Date() },
        { id: 16, name: '商品16', created: new Date() },
        { id: 17, name: '商品17', created: new Date() },
        { id: 18, name: '商品18', created: new Date() },
        { id: 19, name: '商品19', created: new Date() },
        { id: 20, name: '商品20', created: new Date() }
    ]
});

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

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

ツールバーの次のページボタンを押すと、期待通りに動作することがわかります。

コードは、pageSizeとenablePagingを追加しました。

pageSizeは1ページに何件表示させるかを設定します。デフォルト値は25です。

enablePagingですが、これはExt.data.proxy.Memory独自のコンフィグです。Ext.data.proxy.Memoryは、あらかじめページングに対応した処理が実装されています。enablePagingをtrueにすることで、それを有効にできるのです。

サーバからデータを取得する場合は別のプロキシを使うことになりますが、ページング用の処理(表示すべきデータに絞るところ)はサーバ側で対応する必要があります。その際、ストアからのリクエストパラメータにstart,limit,pageというページング用の3つのパラメータが付与されているので、その値を使ってレスポンスを調整します。

なお、何度か使ってきたローカルストレージのプロキシは、ページングに対応していないので、自分で拡張する必要があります。

グリッドの機能:グルーピング・サマリー[classic]

グリッドのグルーピングとサマリーの機能を試します。

今回は注文一覧を会社ごとにグルーピングし、売上金額の小計を表示させます。

モデルとストア

そのため、↓のようなモデル、ストアを用意しました。

/**
 * 注文モデルクラス。
 *
 * @class Sample.model.Order
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.Order', {
    extend: 'Ext.data.Model',

    fields: [
        {
            // ID
            name: 'id',
            type: 'int'
        },
        {
            // 商品名
            name: 'name',
            type: 'string'
        },
        {
            // 会社
            name: 'company',
            type: 'string'
        },
        {
            // 金額(単価)
            name: 'price',
            type: 'int'
        },
        {
            // 数量
            name: 'num',
            type: 'int'
        },
        {
            // 売上金額
            name: 'total',
            convert: function (value, record) {
                return record.get('price') * record.get('num');
            },
            depends: [
                'price',
                'num'
            ]
        },
        {
            // 作成日時(注文日時)
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        }
    ]

});

/**
 * 注文ストアクラス。
 *
 * @class Sample.store.Order
 * @extend Ext.data.Store
 */
Ext.define('Sample.store.Order', {
    extend: 'Ext.data.Store',
    alias: 'store.order',

    requires: [
        'Ext.data.proxy.LocalStorage',
        'Sample.model.Order'
    ],

    model: 'Sample.model.Order',

    proxy: {
        type: 'localstorage',
        id: 'order'
    }
});

モデルのフィールドにconvertを使っています。これは他のフィールドを使って、新しくフィールドを定義する場合に使います。今回は「注文の小計」を表すフィールドを「単価 × 数量」として定義しました。

あとdependsには、convertで参照しているフィールド名を設定しておきます。dependsを設定しておくと、参照しているフィールドの値が変化したときに自動的に値を変更してくれるようになります。今回はあまり役に立ちませんが、dependsは設定しておいたほうが良いです。

ビュー

グルーピングとサマリーは、Ext.grid.feature.GroupingSummaryを使います。(Ext.grid.feature.Grouping、Ext.grid.feature.Summaryというクラスもあり、GroupingとSummaryをまとめたのがGroupingSummaryです)

featureのクラスは、featuresコンフィグにftype: (エイリアス名)と指定します。

/**
 * 注文一覧グリッドクラス。
 *
 * @class Sample.view.main.List
 * @extend Ext.grid.Panel
 */
Ext.define('Sample.view.main.List', {
    extend: 'Ext.grid.Panel',
    xtype: 'main_list',

    requires: [
        'Ext.grid.feature.GroupingSummary'
    ],

    title: '注文一覧',

    store: {
        type: 'order',
        autoLoad: true,
        groupField: 'company'
    },

    features: [
        {
            ftype: 'groupingsummary',
            groupHeaderTpl: '会社: {name}'
        }
    ],

    columns: [
        {
            dataIndex: 'created',
            text: '注文日時',
            xtype: 'datecolumn',
            format: 'Y/m/d H:i',
            width: 150
        },
        {
            dataIndex: 'name',
            text: '商品名',
            width: 300
        },
        {
            dataIndex: 'total',
            text: '売上金額',
            width: 200,
            align: 'right',
            renderer: function (value) {
                return Ext.util.Format.currency(value, '円', 0, true);
            },
            summaryType: 'sum',
            summaryRenderer: function(value){
                return '小計: ' + Ext.util.Format.currency(value, '円', 0, true);
            }
        }
    ]
});

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

グルーピングは、storeコンフィグのgroupFieldで指定しています。今回は会社ごとにグルーピングしたいので、groupField: 'company'としました。

サマリーは集計したいカラムにsummaryTypeとsummaryRendererを指定します(summaryRendererは任意)。summaryTypeには集計方法として、count、sum、min、max、averageが指定できます。summaryRendererは集計結果をどのように出力するかを関数で定義します。

groupHeaderTplでグルーピングした見出しのテキストを設定できます。

補足

今回はstoreの指定をオブジェクトリテラルにしました。

これまではstore: '(ストアID)'で指定していました。これはグローバルなストアを指定するという意味です。Application.jsでstoresにストアを設定すると、自動的にアプリケーション全体でグローバルなストアが1つ作成されます。その自動的に作成されたストアを使うよ、としていたわけです。

storeの指定をオブジェクトリテラルにしてtypeにストアのエイリアス名を指定すると、ストアのインスタンスを新しく作成して、それを使ってくれます。これはグローバルなストアとは全く別物です。今回はautoLoadとgroupFieldを設定したかったので、このようにしました。

 

 

 

ちなみにデータ投入は、画面を開いてからDeveloper Toolsのコンソールなどから↓のように直接実行すれば楽です(グローバルなストアを参照してるので、Application.jsのstoresにOrderストアを設定しといてくださいね)。

Ext.getStore('Order').add(
  { name: 'Tシャツ', company: '株式会社ABC', price: 1000, num: 2, created: new Date() },
  { name: 'パーカー', company: '株式会社ABC', price: 5000, num: 1, created: new Date() },
  { name: 'コート', company: '株式会社ABC', price: 12000, num: 1, created: new Date() },
  { name: 'バスタオル', company: '株式会社XYZ', price: 2000, num: 1, created: new Date() },
  { name: 'フェイスタオル', company: '株式会社XYZ', price: 500, num: 5, created: new Date() }
);
Ext.getStore('Order').sync();

グリッドの機能:選択方法(セレクションモデル)[classic] (2)

Ext.grid.selection.SpreadsheetModelでExcelっぽい感じにできるので、今回はこれを試してみました(一度も使ったことがなかったので、ただの好奇心です)。

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.selection.SpreadsheetModel',
        'Ext.grid.plugin.CellEditing',
        'Ext.grid.plugin.Clipboard'
    ],

    store: 'Memo',

    columnLines: true,

    selModel: {
        type: 'spreadsheet',
        columnSelect: true
    },

    plugins: [
        {
            ptype: 'cellediting'
        },
        {
            ptype: 'clipboard'
        }
    ],

    columns: [
        {
            dataIndex: 'title',
            text: 'タイトル',
            editor: 'textfield',
            width: 250,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        },
        {
            dataIndex: 'body',
            text: '本文',
            editor: 'textarea',
            flex: 1,
            renderer: function (value) {
                value = Ext.String.htmlEncode(value);
                return value.replace(/\r?\n/g, '<br/>');
            }
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i:s',
            dataIndex: 'created',
            text: '登録日時',
            width: 200,
            editor: {
                xtype: 'datefield',
                format: 'Y/m/d H:i:s',
                hideTrigger: true
            }
        }
    ]
});

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

セレクションモデルをExt.grid.selection.SpreadsheetModelにすると、スプレッドシート形式になります。

columnSelectをtrueにすると、ヘッダーをクリックすることで列の一括選択ができます。

さらにExt.grid.plugin.Clipboardプラグインを組み込むことで、Ctrl+C、Ctrl+Vでコピー&ペーストできるようになります。

あとは編集もプラグインで通常通りにできます。

(´-`).。oO(さて、どういう場面で使うのだろうか…)

グリッドの機能:ドラッグ&ドロップ[classic]

Ext.grid.plugin.DragDropプラグインを使うと、グリッドの行をドラッグ&ドロップで操作できます。

プラグインを組み込む

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.column.Date',
        'Ext.grid.plugin.DragDrop'
    ],

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            width: 250,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        },
        {
            dataIndex: 'body',
            text: '本文',
            flex: 1,
            renderer: function (value) {
                value = Ext.String.htmlEncode(value);
                return value.replace(/\r?\n/g, '<br/>');
            }
        },
        {
            xtype: 'datecolumn',
            format: 'Y/m/d H:i:s',
            dataIndex: 'created',
            text: '登録日時',
            width: 200
        }
    ]
});

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

編集用のプラグインと違い、viewConfigにプラグインを設定します。viewConfigは、グリッドが内部で生成するExt.view.Tableに渡されるコンフィグです。

とりあえず、これだけでグリッドの行をドラッグ&ドロップで移動できるようになりました。

次に、2つのグリッドを作成して、グリッド間で行を移動させてみました。グリッドの実装はほとんど変わりません。

/**
 * メモストアクラス。
 *
 * @class Memo.store.Memo
 * @extend Ext.data.Store
 */
Ext.define('Memo.store.Memo', {
    extend: 'Ext.data.Store',

    requires: [
        'Ext.data.proxy.LocalStorage',
        'Memo.model.Memo'
    ],

    model: 'Memo.model.Memo',

    proxy: {
        type: 'localstorage',
        id: 'memo'
    }
});

/**
 * 選択メモストアクラス。
 *
 * @class Memo.store.SelectMemo
 * @extend Ext.data.Store
 */
Ext.define('Memo.store.SelectMemo', {
    extend: 'Ext.data.Store',

    requires: [
        'Memo.model.Memo'
    ],

    model: 'Memo.model.Memo',

    proxy: 'memory'
});

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-grid',

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ]

});

/**
 * 選択メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.SelectGrid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.SelectGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_selectgrid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-selectgrid',

    store: 'SelectMemo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ]

});

/**
 * メモ一覧パネルクラス。
 *
 * @class Memo.view.list.Panel
 * @extend Ext.Panel
 */
Ext.define('Memo.view.list.Panel', {
    extend: 'Ext.Panel',
    xtype: 'list_panel',

    requires: [
        'Memo.view.list.Grid',
        'Memo.view.list.SelectGrid',
        'Memo.view.list.ViewController'
    ],

    controller: 'list',

    layout: {
        type: 'hbox',
        align: 'stretch'
    },

    listeners: {
        showscreen: 'onShowScreen'
    },

    defaults: {
        flex: 1
    },

    items: [
        {
            xtype: 'list_grid',
            padding: '30 15 30 30'
        },
        {
            xtype: 'list_selectgrid',
            padding: '30 30 30 15'
        }
    ]
});

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

↑では、左のグリッドから右のグリッドへ移動させています。逆向きの移動も可能です。

プラグインは最初の例と同じく単純に設定した状態です。特に指定がなければ、別のグリッドでも移動が可能であることがわかります。

移動を制限する

Ext.grid.plugin.DragDropのddGroupコンフィグが一致しない場合は移動できません。特に指定しない場合は、デフォルト値GridDDとなっています。

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-grid',

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop',
                ddGroup: 'memo1'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ]

});

/**
 * 選択メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.SelectGrid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.SelectGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_selectgrid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-selectgrid',

    store: 'SelectMemo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop',
                ddGroup: 'memo2'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ]

});

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

↑のように、移動させようとすると×アイコンが表示されました。

他にもenableDragやenableDropコンフィグを使うと、ドラッグだけ許可したり、ドロップだけ許可したりといった制限も付けられます。

データによって移動を制限したいのであれば、イベントハンドラで処理することもできます。

/**
 * 選択メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.SelectGrid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.SelectGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_selectgrid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-selectgrid',

    store: 'SelectMemo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ],

    listeners: {
        beforedrop: function (node, data, overModel, dropPosition, dropHandlers) {
            var records = data.records;  // recordsに選択行のモデルが配列でセットされている(複数行移動の場合があるので配列)
            return false;
        }
    }
});

例えば↑のように、beforedropイベントでfalseを返すとドロップがキャンセルされます。dataに設定されているモデルの内容によって、falseを返すことで移動を制限することができます。

ちなみに、falseを返す代わりに、dropHandlers.cancelDrop()を実行してもキャンセルできます。違いは元の位置に戻るアクションが付くかどうかです。

そもそも特定の行をドラッグできないようにしたいんだけど、という場合は一応ドラッグする側のグリッドのbeforeitemmousedownイベントでfalseを返すと止めることはできます。

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-grid',

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop',
                containerScroll: true
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ],

    listeners: {
        beforeitemmousedown: function (grid, record, item, index, e) {
            return false;
        }
    }
});

↑は仕様によっては問題が発生するかもしれません。編集できるようにしている場合に処理が中断されることがありそうですし、複数行の場合にどうするかといった問題も発生しそうです。ここでは、ドラッグ操作以前のイベントで禁止にできなくはないということで一応紹介しました。

移動ではなくコピーする

Ext.grid.plugin.DragDropのcopyコンフィグをtrueにするとコピーになります。

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-grid',

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop',
                copy: true
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ]

});

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

データに応じてコピーしたい場合は、イベントハンドラで処理します。

/**
 * 選択メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.SelectGrid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.SelectGrid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_selectgrid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-selectgrid',

    store: 'SelectMemo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ],

    listeners: {
        beforedrop: function (node, data, overModel, dropPosition, dropHandlers) {
            data.copy = true;
        }
    }
});

↑のようにbeforedropイベントの第2引数dataのcopyプロパティをtrueにするとコピーになるので、data.recordsの値に応じた対応が可能です。

ドラッグに合わせてスクロールしたい

Ext.grid.plugin.DragDropプラグインのcontainerScrollコンフィグをtrueにすると、移動のドラッグ中にグリッドのスクロール位置を調整してくれるようになります。

移動後に行の位置を保存する

ブラウザのリロード後も、移動した行の位置を維持したいのであれば、位置をデータとして残す必要があります。そのためには、位置を表す項目をモデルが持っておくべきです。

ここでは、位置を表す項目としてsortフィールドを追加してみます。

/**
 * メモモデルクラス。
 *
 * @class Memo.model.Memo
 * @extend Ext.data.Model
 */
Ext.define('Memo.model.Memo', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'id',
            type: 'int'
        },
        {
            name: 'title',
            type: 'string'
        },
        {
            name: 'body',
            type: 'string'
        },
        {
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        },
        {
            name: 'sort',
            type: 'int'
        }
    ],

    proxy: {
        type: 'localstorage',
        id: 'memo'
    }
});

次に、ストアの並び順を指定します。

/**
 * メモストアクラス。
 *
 * @class Memo.store.Memo
 * @extend Ext.data.Store
 */
Ext.define('Memo.store.Memo', {
    extend: 'Ext.data.Store',

    requires: [
        'Ext.data.proxy.LocalStorage',
        'Memo.model.Memo'
    ],

    model: 'Memo.model.Memo',

    proxy: {
        type: 'localstorage',
        id: 'memo'
    },

    remoteSort: true,

    sorters: [
        {
            property: 'sort',
            direction: 'ASC'
        }
    ]
});

sortersコンフィグを指定することで、ストア内のデータの並び順を設定できます。

remoteSortをtrueにすると、サーバ側に並べ替えを委ねることができます(ただ、今はプロキシをローカルストレージにしているので、実際にはプロキシクラスの内部でソートしてるんですが)。

今回remoteSortをtrueにしているのは、ストアのeachで並び順をまとめて変更するためです。でなければ、eachのループ順が常にsortersの影響を受けてしまいます。

最後に、dropイベントでsortを変更し、ローカルストレージに反映します。(ビューコントローラはもう省略してますよ。まあストアをロードしてるだけです)

/**
 * メモ一覧グリッドクラス。
 *
 * @class Memo.view.list.Grid
 * @extend Ext.grid.Panel
 */
Ext.define('Memo.view.list.Grid', {
    extend: 'Ext.grid.Panel',
    xtype: 'list_grid',

    requires: [
        'Ext.grid.plugin.DragDrop'
    ],

    cls: 'list-grid',

    store: 'Memo',

    viewConfig: {
        plugins: [
            {
                ptype: 'gridviewdragdrop'
            }
        ]
    },

    columns: [
        {
            dataIndex: 'id',
            text: 'ID',
            align: 'right'
        },
        {
            dataIndex: 'title',
            text: 'タイトル',
            flex: 1,
            renderer: function (value) {
                return Ext.String.htmlEncode(value);
            }
        }
    ],

    listeners: {
        drop: function () {
            var store = Ext.getStore('Memo');

            store.each(function (record, index) {
                record.set('sort', index + 1);
            });

            store.sync();
        }
    }
});

/**
 * メモ一覧パネルクラス。
 *
 * @class Memo.view.list.Panel
 * @extend Ext.Panel
 */
Ext.define('Memo.view.list.Panel', {
    extend: 'Ext.Panel',
    xtype: 'list_panel',

    requires: [
        'Memo.view.list.Grid',
        'Memo.view.list.ViewController'
    ],

    controller: 'list',

    layout: 'fit',

    listeners: {
        showscreen: 'onShowScreen'
    },

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

これでうまくいきました。

クラスのオーバーライド

今回はクラスのオーバーライドです。

クラスをオーバーライドすると、対象クラスのメソッドを上書きしたり、プロパティやメソッドを追加したりできます。

私がオーバーライドを使うのは↓のような場合です。

  • フレームワークが提供しているクラスに不具合があり修正が必要・・・
  • 良く使う機能を追加したい・・・
  • 必ず含めないといけないリクエストパラメータを追加したい・・・

オーバーライドしてみる

試しにExt.form.field.Textをオーバーライドしてみましょう。

app.jsonには↓の定義があり、オーバーライド用のファイルはoverrides、classic/overrides、modern/overridesディレクトリに作成していきます。

"overrides": [
    "overrides",
    "${toolkit.name}/overrides"
]

classicだけに適用したいので、classic/overridesにform/field/Text.jsを作成してみます。

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

最低限の記述は↓のようになります。overrideプロパティにオーバーライドしたいクラスを指定します。クラス名は何でも良いですが、私は大体、アプリケーション名.overrides.(オーバーライドするクラスのExtを除いた部分)とします。重複しないから。

/**
 * Ext.form.field.Textのオーバーライドクラス。
 *
 * @class Memo.overrides.form.field.Text
 * @override Ext.form.field.Text
 */
Ext.define('Memo.overrides.form.field.Text', {
    override: 'Ext.form.field.Text'
});

試しにpaddingを設定してみます。

/**
 * Ext.form.field.Textのオーバーライドクラス。
 *
 * @class Memo.overrides.form.field.Text
 * @override Ext.form.field.Text
 */
Ext.define('Memo.overrides.form.field.Text', {
    override: 'Ext.form.field.Text',

    padding: 20

});

↓こんな感じにpaddingが反映されてますね。テキストエリアはExt.form.field.Textを継承しているので、そちらも影響を受けているのが分かります。

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

特定の機能を追加してみる

私がオーバーライドで良く作成する処理を1つ紹介します。入力フィールドをclearable: trueにすると、値が入力されるとクリアボタンが表示されるようにしています。

/**
 * Ext.form.field.Textのオーバーライドクラス。
 *
 * @class Memo.overrides.form.field.Text
 * @override Ext.form.field.Text
 */
Ext.define('Memo.overrides.form.field.Text', {
    override: 'Ext.form.field.Text',

    /**
     * @cfg {Boolean} trueを設定するとクリア機能が利用できる。
     */
    clearable: false,

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

        config = config || {};

        if (config.clearable) {
            config.triggers = Ext.apply(config.triggers || {}, {
                clear: {
                    weight: 0,
                    cls: Ext.baseCSSPrefix + 'form-clear-trigger',
                    hidden: true,
                    handler: 'onClearClick',
                    scope: 'this'
                }
            })
        }

        me.callParent(arguments);

        if (config.clearable) {
            me.on('change', 'onChange', me);
        }
    },

    // @override
    afterRender: function(){
        var me = this;
        me.callParent();

        me.onChange();
    },

    /**
     * クリアボタンクリック時の処理。
     */
    onClearClick : function(){
        this.setValue('');
    },

    /**
     * changeイベント発火時の処理。
     */
    onChange: function() {
        var me = this,
            value = String(me.getValue() || '');

        value.length > 0 ? me.showClearButton() : me.hideClearButton();
    },

    /**
     * クリアボタンを表示する
     */
    showClearButton: function() {
        var me = this;

        if(me.triggers.clear) {
            me.triggers.clear.show();
        }
    },

    /**
     * クリアボタンを非表示にする
     */
    hideClearButton: function() {
        var me = this;

        if(me.triggers.clear) {
            me.triggers.clear.hide();
        }
    }
});

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

便利な機能の1つだと思いますが、継承しているクラスにも影響するので、オーバーライドしても大丈夫か注意して使うようにしましょう。