初心者のためのExtJS入門

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

レイアウト[classic] (1)

今回はclassicのレイアウトを取り上げます(何回かに分けます)。

レイアウトの種類によって、パネルやコンテナに設置するアイテムコンポーネントの配置が変わります。

レイアウトは、layoutコンフィグで設定します。

Ext.layout.container.Auto

これは特にレイアウトを指定していない場合に適用されているデフォルトのレイアウトです。

この場合、単純にDOMを順番に配置するだけです。アイテムコンポーネントのスタイル次第です。

アイテムコンポーネントがdisplay: blockなら、縦に並びます。float: leftでも付けていれば、横に並ぶことでしょう。

/**
 * レイアウトAutoのパネル。
 *
 * @class Sample.view.main.layout.AutoPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.AutoPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_auto_panel',

    scrollable: true,

    layout: 'auto',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1'
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2'
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

Ext.layout.container.Fit

アイテムコンポーネントを、layoutコンフィグを設定した親コンポーネントのサイズまで目一杯広げて設置するレイアウトです。

アイテムコンポーネントが複数ある場合は、表示できるものが1つだけ表示されます。

/**
 * レイアウトFitのパネル。
 *
 * @class Sample.view.main.layout.FitPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.FitPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_fit_panel',

    layout: 'fit',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1',
            hidden: true
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2'
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

Ext.layout.container.HBox

アイテムコンポーネントを横に並べるレイアウトです。

アイテムコンポーネントが右端に到達しても折り返しされません。

/**
 * レイアウトHBoxのパネル。
 *
 * @class Sample.view.main.layout.HboxPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.HboxPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_hbox_panel',

    scrollable: true,

    layout: 'hbox',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1'
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2'
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

レイアウトのalignコンフィグで、アイテムコンポーネントの縦方向に対する設定が可能です。alignは↓のように指定します。

layout: {
    type: 'hbox',
    align: 'middle'
}
middle end stretch stretchmax
縦の位置を中央揃えにします。 縦の位置を下端に揃えます 高さを目一杯広げます 高さを一番高いアイテムコンポーネントに揃えます
f:id:sham-memo:20170401154648p:plain f:id:sham-memo:20170401155556p:plain f:id:sham-memo:20170401155313p:plain f:id:sham-memo:20170401155745p:plain

レイアウトのpackコンフィグで、アイテムコンポーネントの横方向に対する設定が可能です。

center end
横の位置を中央揃えにします 横の位置を右端に寄せます
f:id:sham-memo:20170401160336p:plain f:id:sham-memo:20170401160159p:plain

アイテムコンポーネントflexを指定することで、flexを指定したアイテムコンポーネントだけ目一杯幅を広げることができます。

/**
 * レイアウトHBoxのパネル。
 *
 * @class Sample.view.main.layout.HboxPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.HboxPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_hbox_panel',

    scrollable: true,

    layout: 'hbox',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1',
            flex: 1
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2',
            flex: 2
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

flexを指定したアイテムコンポーネントがある場合、余った部分をflexの値の比率で分け合います。

上記の例だと、親パネルの幅からアイテムコンポーネント3の幅を引いた部分が余った部分で、その余った部分を、アイテムコンポーネント1 : アイテムコンポーネント2 = 1 : 2の比率で分け合います。

Ext.layout.container.VBox

アイテムコンポーネントを縦に並べるレイアウトです。

向きが変わるだけで、Ext.layout.container.HBoxと同じようなことができます。

/**
 * レイアウトVBoxのパネル。
 *
 * @class Sample.view.main.layout.VboxPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.VboxPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_vbox_panel',

    layout: 'vbox',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1'
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2'
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

レイアウトのalignコンフィグで、アイテムコンポーネントの横方向に対する設定が可能です。alignは↓のように指定します。

middle end stretch stretchmax
横の位置を中央揃えにします。 横の位置を右端に揃えます 幅を目一杯広げます 幅を一番広いアイテムコンポーネントに揃えます
f:id:sham-memo:20170403225708p:plain f:id:sham-memo:20170403225822p:plain f:id:sham-memo:20170403225946p:plain f:id:sham-memo:20170403230208p:plain

レイアウトのpackコンフィグで、アイテムコンポーネントの縦方向に対する設定が可能です。

center end
縦の位置を中央揃えにします 縦の位置を下端に寄せます
f:id:sham-memo:20170403231149p:plain f:id:sham-memo:20170403231255p:plain

アイテムコンポーネントflexを指定することで、flexを指定したアイテムコンポーネントだけ目一杯高さを広げることができます。

/**
 * レイアウトVBoxのパネル。
 *
 * @class Sample.view.main.layout.VboxPanel
 * @extend Ext.Panel
 */
Ext.define('Sample.view.main.layout.VboxPanel', {
    extend: 'Ext.Panel',
    xtype: 'layout_vbox_panel',

    layout: 'vbox',

    items: [
        {
            xtype: 'component',
            cls: 'item-cmp1',
            html: 'アイテムコンポーネント1',
            flex: 1
        },
        {
            xtype: 'component',
            cls: 'item-cmp2',
            html: 'アイテムコンポーネント2',
            flex: 2
        },
        {
            xtype: 'component',
            cls: 'item-cmp3',
            html: 'アイテムコンポーネント3'
        }
    ]
});

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

Ext.XTemplateの使い方

今回は、Ext.XTemplateを取り上げます。

Ext.XTemplateはテンプレート機能を提供してくれます。同じようなパターンの文字列を何度も作成する場合に便利です。

多くのjavascriptフレームワークでも実装されており、HTMLタグ文字列を作成するのに使われることが多くです。

ExtJSでも同様に、多くのコンポーネントクラスの実装で使われており、使えるようになるとExtJSでの開発が非常に楽になります。

まずは使ってみる

これは挨拶の文字列を出力する簡単なサンプルです。

var tpl = new Ext.XTemplate('{name}さん、こんにちは');

var str = tpl.apply({
    name: '山田太郎'
});

console.log(str);

基本的な使い方は↓です。

1. Ext.XTemplateのコンストラクタにテンプレート用の文字列を渡す。
2. applyメソッドに埋め込みデータを渡す

applyメソッド以外にもデータを渡すためのメソッドは存在しますが、単純な文字列を返す場合はapplyメソッドを使います。

条件分岐

データの内容によって、テンプレート文字列で条件分岐することができます。

テンプレート文字列でを使うと、if内がtrueの場合、囲った部分が出力されます。

var tpl = new Ext.XTemplate(
    '{name}<tpl if="kana">({kana})</tpl>さん、こんにちは'
);

var str = tpl.apply({
    name: '山田太郎',
    kana: 'やまだたろう'
});

console.log(str);

上記のサンプルでは、埋め込みデータにkanaがあれば、ふりがなを括弧付きで出力します。

applyメソッドに渡すオブジェクトリテラルからkanaを消してみると、括弧部分も表示されなくなります。

大小関係を条件にする場合は、下記のようになります。(tplタグ内に書くので括弧が使えないのは良いとして、leやgeが存在しないのが謎です)

一般的なオペレータ テンプレート文字列でのオペレータ
> &gt;
>= &gt;=
< &lt;
<= &lt;=
== ==
var tpl = new Ext.XTemplate(
    '商品名: {name}\n',
    'サイズ: ',
    '<tpl if="weight &gt;= 1000">',
        '大',
    '<tpl elseif="weight &gt;= 500">',
        '中',
    '<tpl else>',
        '小',
    '</tpl>'
);

var str = tpl.apply({
    name: 'りんご',
    weight: 500
});

console.log(str);

これを実行するとサイズは「中」と出力されます。

ついでに使いましたが、elseifやelseで複数条件を指定することもできます。

繰り返し

を使って、配列なども繰り返し処理できます。

var tpl = new Ext.XTemplate(
    '<tpl for="items">',
        '商品名: {name}, 価格: {price}円\n',
    '</tpl>'
);

var str = tpl.apply({
    items: [
        {
            name: '帽子',
            price: 1800
        },
        {
            name: 'Tシャツ',
            price: 1000
        },
        {
            name: 'バッグ',
            price: 5000
        }
    ]
});

console.log(str);

forには、繰り返し処理するデータのプロパティ名を指定します。

すると、内では、繰り返し処理中の1件分が処理のスコープとなり、サンプルのように直接{name}や{price}が指定できます。

親のデータにアクセスしないといけない場合は、parentを通して取得します。

var tpl = new Ext.XTemplate(
    '<tpl for="items">',
        '商品名: {name}, 価格: {price}円, 税率: {parent.tax}\n',
    '</tpl>'
);

var str = tpl.apply({
    tax: 0.08,
    items: [
        {
            name: '帽子',
            price: 1800
        },
        {
            name: 'Tシャツ',
            price: 1000
        },
        {
            name: 'バッグ',
            price: 5000
        }
    ]
});

console.log(str);

ループのインデックス番号などもアクセスできます。

インデックス番号の場合は「xindex」です。他にもあるのでドキュメントを参照してみてください(http://docs.sencha.com/extjs/6.2.1/modern/Ext.XTemplate.html)。

var tpl = new Ext.XTemplate(
    '<tpl for="text">',
        '{% if (xindex % 2 === 0) continue; %}',
        '{.}\n',
        '{% if (xindex > 8) break; %}',
    '</tpl>'
);

var str = tpl.apply({
    text: [
        'item1',
        'item2',
        'item3',
        'item4',
        'item5',
        'item6',
        'item7',
        'item8',
        'item9',
        'item10'
    ]
});

console.log(str);

{% ... %}は、出力を伴わないような制御処理で使えます。出力まで伴う場合には、{[...]}を使うことができます(次のセクション)。

計算処理やメソッド定義

テンプレート内で同じような判定や計算があれば、メソッドにまとめることができます。

var tpl = new Ext.XTemplate(
    '<tpl for="items">',
        '商品名: {name}, 価格: {price}円',
        '<tpl if="!this.isTaxFree(values)">',
            ', 消費税: {[this.calcTax(values.price, parent.taxRate)]}円\n',
        '</tpl>',
    '</tpl>',
    {
        isTaxFree: function (values) {
            return values.taxFree;
        },
        calcTax: function (price, taxRate) {
            return Math.floor(price * taxRate);
        }
    }
);

var str = tpl.apply({
    taxRate: 0.08,
    items: [
        {
            name: '帽子',
            price: 1800,
            taxFree: false
        },
        {
            name: 'Tシャツ',
            price: 1000,
            taxFree: false
        },
        {
            name: 'バッグ',
            price: 5000,
            taxFree: true
        }
    ]
});

console.log(str);

Ext.XTemplateの最後にオブジェクトリテラルでメソッドを定義することができます。

出力させる場合は{[...]}に記述します。定義したメソッドはExt.XTemplateのメンバーメソッドになるので、thisキーワードから実行します。

埋め込みデータはvaluesに格納されているので、そこを経由してアクセスできます。

また、ifでもメソッドを使えるので、条件が複雑な場合はメソッドを定義すると便利です。

コンポーネントで使ってみる

最後にコンポーネントで使ってみましょう。

Ext.define('Sample.view.main.Panel', {
    extend: 'Ext.panel.Panel',
    xtype: 'main_panel',

    bodyPadding: 20,

    tpl: [
        '<header>',
            '<h1>{title}</h1>',
        '</header>',
        '<section>',
            '<p>{content}</p>',
        '</section>'
    ],

    data: {
        title: 'Ext.XTemplateの紹介',
        content: 'Ext.XTemplateを紹介しています。<br>便利な機能なのでぜひ使ってみてください。'
    }
});

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

上記の場合は、tplコンフィグがテンプレート文字列、dataコンフィグがそれに埋め込むデータとなります。

dataが設定されると、内部でExt.XTemplateが使われてDOMが生成・更新されるようになっているのです。

多言語対応[classic]

今回は言語の切り替えを試してみます。

おおまかなポイントを列挙しました。

  • 言語はプルダウンで選択する
  • 選択した言語はローカルストレージで保持する
  • ロケールファイルをロードしてから、ビューを作成する

app.jsonの修正

localeの定義は消します。

ext-localeのrequireは残します。

言語はプルダウンで選択する

まずは言語選択用のプルダウンを設置します。

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

    requires: [
        'Ext.picker.Date'
    ],

    title: '多言語対応',

    tools: [
        {
            xtype: 'combo',
            displayField: 'text',
            valueField: 'value',
            queryMode: 'local',
            editable: false,
            forceSelection: true,
            store: {
                fields: ['text', 'value'],
                data: [
                    {'text': 'English', 'value': 'en'},
                    {'text': '日本語', 'value': 'ja'}
                ]
            }
        }
    ],

    items: [
        {
            xtype: 'datepicker'
        }
    ]

});

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

選択した言語はローカルストレージで保持する

ビューコントローラを追加し、言語プルダウンを変更したらローカルストレージに保存するようにします。

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

    requires: [
        'Ext.picker.Date',
        'Sample.view.main.ViewController'
    ],

    controller: 'main',

    title: '多言語対応',

    tools: [
        {
            xtype: 'combo',
            displayField: 'text',
            valueField: 'value',
            queryMode: 'local',
            editable: false,
            forceSelection: true,
            store: {
                fields: ['text', 'value'],
                data: [
                    {'text': 'English', 'value': 'en'},
                    {'text': '日本語', 'value': 'ja'}
                ]
            },
            value: Ext.util.LocalStorage.get('sample').getItem('locale') || 'en',
            listeners: {
                change: 'onChangeLocaleCombo'
            }
        }
    ],

    items: [
        {
            xtype: 'datepicker'
        }
    ]

});

/**
 * ビューコントローラクラス。
 *
 * @class Sample.view.main.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Sample.view.main.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.main',

    requires: [
        'Ext.util.LocalStorage'
    ],

    /**
     * 言語プルダウンchangeイベント時の処理。
     *
     * @param {Ext.form.field.Combo} field プルダウン
     * @param {String} value 値
     */
    onChangeLocaleCombo: function (field, value) {
        var store = Ext.util.LocalStorage.get('sample'),
            currentLocale = store.getItem('locale');

        if (currentLocale !== value) {
            store.setItem('locale', value);
            window.location.reload();
        }
    }
});

これでローカルストレージに選択した言語が保存されます。

保存後は、ロケールファイルをロードするために、window.location.reloadでブラウザをリロードさせます。

ロケールファイルをロードしてから、ビューを作成する

ここからはApplication.jsを修正していきます。

まずはapp.jsのmainViewを削除します。mainViewにはビューポートとなるコンポーネントを定義していますが、これが定義されているとロケールファイルをロードする前にビューが作成されてしまうためです。

とりあえずコメントアウトしました。

/*
 * This file is generated and updated by Sencha Cmd. You can edit this file as
 * needed for your application, but these edits will have to be merged by
 * Sencha Cmd when upgrading.
 */
Ext.application({
    name: 'Sample',

    extend: 'Sample.Application',

    requires: [
        'Sample.view.main.Panel'
    ]

    // The name of the initial view to create. With the classic toolkit this class
    // will gain a "viewport" plugin if it does not extend Ext.Viewport. With the
    // modern toolkit, the main view will be added to the Viewport.
    //
    //mainView: 'Sample.view.main.Panel'
    
    //-------------------------------------------------------------------------
    // Most customizations should be made to Sample.Application. If you need to
    // customize this file, doing so below this section reduces the likelihood
    // of merge conflicts when upgrading to new versions of Sencha Cmd.
    //-------------------------------------------------------------------------
});

次にApplication.jsでロケールファイルをロードします。

/**
 * アプリケーションクラス。
 *
 * @class Sample.Application
 * @extend Ext.app.Application
 */
Ext.define('Sample.Application', {
    extend: 'Ext.app.Application',
    
    name: 'Sample',

    requires: [
        'Ext.util.LocalStorage'
    ],

    /**
     * ローンチ処理。
     */
    launch: function () {
        var me = this;

        me.loadLocaleFile(function () {
            me.setMainView('Sample.view.main.Panel');
        });
    },

    /**
     * ロケールファイルをロードする。
     * @param {Function} [cb] コールバック
     */
    loadLocaleFile: function (cb) {
        var me = this,
            store = Ext.util.LocalStorage.get('sample'),
            locale = store.getItem('locale') || 'en';

        Ext.Loader.loadScript({
            url: me.getLocaleUrl(locale),
            onLoad: function () {
                if (cb) {
                    cb();
                }
            }
        });
    },

    /**
     * ロケールファイルのURLを返す。
     *
     * @param {String} locale 言語名
     * @returns {String} ロケールファイルのURL
     */
    getLocaleUrl: function (locale) {
        // MEMO: プロジェクト毎のロケールファイルのパスを設定する
        return '/ext/classic/locale/overrides/' + locale + '/ext-locale-' + locale + '.js';
    }
});

ロケールファイルをロードした後に、Ext.ApplicationのsetMainViewを呼び出すことで順番を担保しています。

ちなみに上記コードでは、ロケールファイルのパスはサンプル用なので、実際には適宜変更することになります。開発環境や本番環境で差異がある場合もあるでしょう。

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

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

良い感じになりました。

補足

これはあくまでも一例です。

ロケールファイルをロードできれば良いだけなので、scriptタグでロードしようが、JQueryでロードしようが、どういう方法でやっても問題ありません。

日本語化してみる[classic]

今回は日本語化してみましょう。

基本的に言語変更はオーバーライドで文字列を上書きするのですが、classicの場合、パッケージが用意されているので、まずはそれを使います。

/ext/classic/locale/overrides/の下に「af」「bg」といったディレクトリがあります。

日本語は「ja」です。ja/ext-locale-ja.jsを開いてみると、日本語文字列でオーバーライドする処理がたくさん書かれています。

要は、これを組み込めば良いわけです。

app.jsonの変更

まず、app.jsonのrequiresにext-localeを追加します。

"requires": [
    "font-awesome",
    "ext-locale"
]

次にlocaleを追加します。

"locale": "ja"

これでひとまず準備完了です。

確認

sencha app watchして、確認してみます。

分かりやすいExt.picker.Dateを表示してみました。

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

    requires: [
        'Ext.picker.Date'
    ],

    items: [
        {
            xtype: 'datepicker'
        }
    ]

});

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

ちゃんと日本語表記になっていますね。

Developer Toolsで読み込んだjsファイルを確認すると、ext-locale-ja.jsが含まれているのが分かります。

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

次は多言語対応を試してみます。

モデルのアソシエーション

ExtJSのモデルは「1 対 1」「1 対 複数」といったデータモデル上の関係を持つことができます(アソシエーション機能)。

例えば、下のようなデータをモデルで取り扱う場合に使えます。

{
  "id": 100,
  "created": "2017/03/15 15:20:25",
  "user": {
    "id": 1,
    "name": "山田太郎",
    "kana": "やまだたろう"
  },
  "details": [
    {
      "id": 1,
      "num": 1,
      "item": {
        "id": 1,
        "name": "ジャケット",
        "price": 5000
      }
    },
    {
      "id": 2,
      "num": 2,
      "item": {
        "id": 2,
        "name": "Tシャツ",
        "price": 1000
      }
    }
  ]
}

モデル作成

さきほどのJSONデータは注文データを表しています。

userは購入者、detailsは注文の詳細(どの商品をいくつ注文したのか)を意味しています。

それを踏まえてモデルを作成します。

/**
 * ユーザモデルクラス。
 *
 * @class Sample.model.User
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        {
            // ユーザID
            name: 'id',
            type: 'int'
        },
        {
            // 氏名
            name: 'name',
            type: 'string'
        },
        {
            // かな
            name: 'kana',
            type: 'string'
        }
    ]
});

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

    fields: [
        {
            // 商品ID
            name: 'id',
            type: 'int'
        },
        {
            // 商品名
            name: 'name',
            type: 'string'
        },
        {
            // 単価
            name: 'price',
            type: 'int'
        }
    ]
});

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

    requires: [
        'Sample.model.Item'
    ],

    fields: [
        {
            // 注文詳細ID
            name: 'id',
            type: 'int'
        },
        {
            // 注文数
            name: 'num',
            type: 'int'
        }
    ],

    hasOne: [
        {
            // 注文モデル
            name: 'item',
            model: 'Sample.model.Item'
        }
    ]
});

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

    requires: [
        'Sample.model.User',
        'Sample.model.OrderDetail'
    ],

    fields: [
        {
            // 注文ID
            name: 'id',
            type: 'int'
        },
        {
            // 作成日時(注文日時)
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        }
    ],

    hasOne: [
        {
            name: 'user',
            model: 'Sample.model.User'
        }
    ],

    hasMany: [
        {
            name: 'details',
            model: 'Sample.model.OrderDetail'
        }
    ]
});

hasOneとhasManyでアソシエーションを設定しています。

ひとつのデータであればhasOne、複数のデータであればhasManyで表現できます。

nameに指定する文字列は、JSONのプロパティ名と合わせます。

modelには、モデルクラス名をフルネームで指定します。

(Application.jsなどでモデルをrequiresするのをお忘れなく)

モデルにプロキシを設定

今回は非同期でデータをロードすることにしました。

モデルにExt.data.proxy.Ajaxプロキシを設定します。

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

    requires: [
        'Sample.model.User',
        'Sample.model.OrderDetail',
        'Ext.data.proxy.Ajax',
        'Ext.data.reader.Json'
    ],

    fields: [
        {
            // 注文ID
            name: 'id',
            type: 'int'
        },
        {
            // 作成日時(注文日時)
            name: 'created',
            type: 'date',
            dateFormat: 'Y/m/d H:i:s'
        }
    ],

    hasOne: [
        {
            // ユーザ
            name: 'user',
            model: 'Sample.model.User'
        }
    ],

    hasMany: [
        {
            // 注文詳細
            name: 'details',
            model: 'Sample.model.OrderDetail'
        }
    ],

    proxy: {
        type: 'ajax',
        url: '/data/order.json',
        reader: {
            type: 'json',
            rootProperty: 'data'
        }
    }
});

これでSample.model.Order.loadを実行したときに、proxyコンフィグに設定した内容でロードしてくれます。

ダミーデータを作成

ダミーデータを作成します。

プロキシのURLを/data/order.jsonとしているので、このファイルをプロジェクトに作成します。

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

{
  "success": true,
  "data": {
    "id": 100,
    "created": "2017/03/15 15:20:25",
    "user": {
      "id": 1,
      "name": "山田太郎",
      "kana": "やまだたろう"
    },
    "details": [
      {
        "id": 1,
        "num": 1,
        "item": {
          "id": 1,
          "name": "ジャケット",
          "price": 5000
        }
      },
      {
        "id": 2,
        "num": 2,
        "item": {
          "id": 2,
          "name": "Tシャツ",
          "price": 1000
        }
      }
    ]
  }
}

これで http://localhost:1841/data/order.json にアクセスできるようになりました。

ロードしてみる

試しにロードしてみます。

Developer Toolsなどのコンソールから下を実行すると、ログが出力されればOKです。

Sample.model.Order.load(100, {
    success: function (record) {
        console.log(record);
        console.log(record.getUser());
        console.log(record.details());
    }
});

record変数に、ロードしたデータを保持したSample.model.Orderのインスタンスが設定されます。

注文モデルからユーザモデルを参照するには自動的に定義された「getUser」メソッドを使います。

また、注文詳細は自動的に定義された「details」メソッドを使います。detailsメソッドの戻り値は注文詳細モデルのストアになっています。

(loadメソッドの第1引数には注文IDを指定しますが、レスポンスデータのidと一致しないとエラーとなるので注意してください)

ロードしたデータを画面に表示する

最後に、ロードしたデータを画面に出力してみます。

ビューモデルがゴチャゴチャしてるのが嫌な感じ。もっと良い書き方がありそう。

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

    requires: [
        'Sample.view.order.detail.Content',
        'Sample.view.main.ViewController',
        'Sample.view.main.ViewModel'
    ],

    controller: 'main',
    viewModel: 'main',

    layout: 'fit',

    items: [
        {
            xtype: 'order_detail_content'
        }
    ],

    listeners: {
        afterrender: 'onAfterRender'
    }

});

/**
 * 注文内容クラス。
 *
 * @class Sample.view.order.detail.Content
 * @extend Ext.Component
 */
Ext.define('Sample.view.order.detail.Content', {
    extend: 'Ext.Component',
    xtype: 'order_detail_content',

    cls: 'order-detail-content',

    bind: {
        data: {
            order: '{order}',
            user: '{user}',
            orderDetails: '{orderDetails}'
        }
    },

    tpl: [
        '<h1>#{order.id} 注文情報</h1>',
        '<table>',
            '<tbody>',
                '<tr>',
                    '<th>注文者</th>',
                    '<td>{user.name}</td>',
                '</tr>',
                '<tr>',
                    '<th>注文日時</th>',
                    '<td>{[Ext.Date.format(values.order.created, "Y/m/d H:i")]}</td>',
                '</tr>',
            '</tbody>',
        '</table>',
        '<h2>注文詳細</h2>',
        '<table>',
            '<tbody>',
                '<tr>',
                    '<th>商品名</th>',
                    '<th class="right">単価</th>',
                    '<th class="right">数量</th>',
                    '<th class="right">小計</th>',
                '</tr>',
                '<tpl for="orderDetails">',
                    '<tr>',
                        '<td>{item.name}</td>',
                        '<td class="right">{item.price}円</td>',
                        '<td class="right">{num}</td>',
                        '<td class="right">{[values.item.price * values.num]}円</td>',
                    '</tr>',
                '</tpl>',
            '</tbody>',
        '</table>',
        '<div class="total">合計: {[this.getTotal(values.orderDetails)]}円</div>',
        {
            getTotal: function (orderDetails) {
                var total = 0;
                Ext.Array.each(orderDetails, function (detail) {
                    total += detail.item.price * detail.num;
                });
                return total;
            }
        }
    ]

});

/**
 * ビューコントローラクラス。
 *
 * @class Sample.view.main.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Sample.view.main.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.main',

    /**
     * afterrenderイベント時の処理。
     */
    onAfterRender: function () {
        var me = this;

        Sample.model.Order.load(100, {
            success: function (record) {
                me.getViewModel().set('record', record);
            }
        });
    }
});

/**
 * ビューモデルクラス。
 *
 * @class Sample.view.main.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Sample.view.main.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.main',

    data: {
        /**
         * @cfg {Sample.model.Order} 注文モデル。
         */
        record: null
    },

    formulas: {
        /**
         * 注文情報を返す。
         *
         * @param {Function} get
         * @returns {Object} 注文情報
         */
        order: function (get) {
            var record = get('record');
            return record ? record.getData() : {};
        },

        /**
         * ユーザ情報を返す。
         *
         * @param {Function} get
         * @returns {Object} ユーザ情報
         */
        user: function (get) {
            var record = get('record');
            return record ? record.getUser().getData() : {};
        },

        /**
         * 注文詳細情報を返す。
         * @param {Function} get
         * @returns {Array} 注文詳細情報
         */
        orderDetails: function (get) {
            var record = get('record');

            if (record) {
                var data = [];
                record.details().each(function (detail) {
                    var item = detail.getItem();
                    data.push(Ext.apply(detail.getData(), {
                        item: item.getData()
                    }));
                });
                return data;
            } else {
                return [];
            }
        }
    }
});
@charset "UTF-8";

.order-detail-content {
  background-color: #f9f9f9;
  padding: 20px;

  h1, h2 {
    font-weight: normal;
    line-height: 1.2;
    margin: 0 0 10px 0;
  }

  table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 15px;

    th, td {
      padding: 10px;
      border: 1px solid #ddd;
    }

    th {
      background-color: #009688;
      color: #fff;
      font-weight: normal;
      text-align: left;
    }

    td {
      background-color: #fff;
    }
  }

  .total {
    font-size: 2.5em;
    line-height: 1.2;
    text-align: right;
    text-decoration: underline;
  }

  .right {
    text-align: right;
  }
}

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

こんな感じになりました。

アソシエーションの機能はなかなか便利です。サーバ側のレスポンスも、あらかじめアソシエーションを考慮した形式にしておくと良いでしょう。

マテリアルテーマのボタンのUI[modern]

modernのテーマには、theme-materialを指定することでマテリアルデザインにできますが、ボタンのUIが通常のテーマとは若干異なります。

ボタンのuiコンフィグに指定できるのは、次のようなものになります。

Ext.define('Sample.view.main.Form', {
    extend: 'Ext.form.Panel',
    xtype: 'main_form',

    requires: [
        'Ext.Button'
    ],

    cls: 'main-form',

    scrollable: true,

    defaults: {
        margin: '0 0 15 0'
    },

    items: [
        {
            xtype: 'button',
            text: 'action',
            ui: 'action'
        },
        {
            xtype: 'button',
            text: 'alt',
            ui: 'alt'
        },
        {
            xtype: 'button',
            text: 'confirm',
            ui: 'confirm'
        },
        {
            xtype: 'button',
            text: 'decline',
            ui: 'decline'
        },
        {
            xtype: 'button',
            text: 'raised',
            ui: 'raised'
        },
        {
            xtype: 'button',
            text: 'raised-confirm',
            ui: 'raised-confirm'
        },
        {
            xtype: 'button',
            text: 'raised-decline',
            ui: 'raised-decline'
        },
        {
            xtype: 'button',
            text: 'segmented',
            ui: 'segmented'
        },
        {
            xtype: 'button',
            iconCls: 'md-icon-add',
            ui: 'fab'
        },
        {
            xtype: 'button',
            text: 'mybutton1',
            ui: 'mybutton1'
        },
        {
            xtype: 'button',
            text: 'mybutton2',
            ui: 'mybutton2'
        }
    ]
});

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

fabまでは標準で用意されているUIです。

mybutton1とmybutton2は追加してみました。

.#{$prefix}button {
  text-transform: inherit !important;
}

$mybutton1-color-name: dynamic('indigo');
$mybutton1-color: dynamic(material-color('indigo', '500'));
$mybutton1-foreground-color: dynamic(material-foreground-color($mybutton1-color-name));
$mybutton1-pressed-color: dynamic(rgba($mybutton1-color, .8));

@include button-ui(
    $ui: 'mybutton1',
    $color: $mybutton1-foreground-color,
    $background-color: $mybutton1-color,
    $pressed-background-color: $mybutton1-pressed-color
);

$mybutton2-color: dynamic(#607D8B);
$mybutton2-foreground-color: dynamic(#ffffff);
$mybutton2-pressed-color: dynamic(rgba($mybutton2-color, .8));

@include button-ui(
    $ui: 'mybutton2',
    $color: $mybutton2-foreground-color,
    $background-color: $mybutton2-color,
    $pressed-background-color: $mybutton2-pressed-color
);

ext/modern/theme-material/sass/var/Button.scss を見るとどのようにuiが定義されているかを追うことができると思います。

'indigo'はtheme-materialであらかじめ用意されている色で、ext/modern/theme-material/sass/etc/functions/color.scss に定義してあります。

マテリアルデザインの色については、 https://material.io/guidelines/style/color.html#color-color-palette が役に立つと思います。

バリデーション:classicのようにエラーテキストを画面に表示させる[modern]

※ExtJS6.5.0からmodernのエラー表示の機能が強化され、標準機能で実現できるようになっています。

modernの場合、標準で提供されている入力エラーの表示方法が乏しいです。

でも画面にも表示できたほうがUIとしては良いので、エラーテキストを入力フィールドの下に表示させてみました。

Ext.field.Textをオーバーライド

まずはエラーテキストを表示するための機能を、オーバーライドで追加しました。

/**
 * テキストフィールドのオーバーライドクラス。
 *
 * @class Sample.overrides.field.Text
 * @override Ext.field.Text
 */
Ext.define('Sample.overrides.field.Text', {
    override: 'Ext.field.Text',

    /**
     * @cfg {String} エラー時に付与されるセレクタ名。
     */
    errorCls: Ext.baseCSSPrefix + 'has-error',

    // @override
    getElementConfig: function() {
        return {
            reference: 'element',
            children: [
                {
                    reference: 'labelElement',
                    cls: Ext.baseCSSPrefix + 'label-el',
                    children: [{
                        reference: 'labelTextElement',
                        cls: Ext.baseCSSPrefix + 'label-text-el',
                        tag: 'span'
                    }]
                },
                {
                    reference: 'bodyElement',
                    cls: Ext.baseCSSPrefix + 'body-el'
                },
                {
                    reference: 'errorElement',
                    cls: Ext.baseCSSPrefix + 'error-el'
                }
            ]
        };
    },

    /**
     * エラーをマークする。
     * @param {String|Array} messages エラーメッセージ
     */
    markInvalid: function (messages) {
        var me = this,
            errorElement = me.errorElement;

        if (Ext.isString(messages)) {
            messages = [messages];
        }

        if (messages.length > 0) {
            me.addCls(me.errorCls);
        }

        errorElement.setHtml(messages.join('<br>'));
    },

    /**
     * エラーをクリアする。
     */
    clearInvalid: function () {
        var me = this,
            errorElement = me.errorElement;

        me.removeCls(me.errorCls);

        errorElement.setHtml(null);
    }
});

@charset "UTF-8";

.x-textfield {
  &.x-has-error {
    color: #c00;

    .x-input {
      border-color: #c00;
    }
  }
}

getElementConfigメソッドのオーバーライドで、エラーテキストの表示要素を用意し、エラーの表示・クリアのメソッドを追加しました。

これでmarkInvalidメソッドを呼び出すことでエラーテキストを表示できます。

モデル

/**
 * ユーザモデルクラス。
 *
 * @class Sample.model.User
 * @extend Ext.data.Model
 */
Ext.define('Sample.model.User', {
    extend: 'Ext.data.Model',

    fields: [
        {
            name: 'name',
            type: 'string'
        },
        {
            name: 'kana',
            type: 'string'
        },
        {
            name: 'gender',
            type: 'string'
        },
        {
            name: 'age',
            type: 'int'
        },
        {
            name: 'postalCode',
            type: 'string'
        },
        {
            name: 'address',
            type: 'string'
        },
        {
            name: 'note',
            type: 'string'
        }
    ],

    validators: {
        name: [
            {
                type: 'presence'
            },
            {
                type: 'length',
                max: 10
            }
        ],
        kana: [
            {
                type: 'length',
                max: 10
            }
        ],
        gender: 'presence',
        age: [
            {
                type: 'range',
                min: 0
            }
        ]
    }
});

ビュー

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

    requires: [
        'Sample.view.main.Form',
        'Sample.view.main.ViewController',
        'Sample.view.main.ViewModel'
    ],

    controller: 'main',
    viewModel: 'main',

    layout: 'fit',

    items: {
        reference: 'form',
        xtype: 'main_form'
    },

    listeners: {
        show: 'onShow'
    }
});

/**
 * ユーザ登録フォームクラス。
 *
 * @class Sample.view.main.Form
 * @extend Ext.form.Panel
 */
Ext.define('Sample.view.main.Form', {
    extend: 'Ext.form.Panel',
    xtype: 'main_form',

    requires: [
        'Ext.field.Text',
        'Ext.field.Select',
        'Ext.field.Number',
        'Ext.field.TextArea',
        'Ext.Button'
    ],

    cls: 'main-form',

    title: 'ユーザ登録',

    scrollable: true,

    bind: {
        record: '{user}'
    },

    items: [
        {
            xtype: 'textfield',
            name: 'name',
            label: '氏名'
        },
        {
            xtype: 'textfield',
            name: 'kana',
            label: 'かな'
        },
        {
            xtype: 'selectfield',
            name: 'gender',
            label: '性別',
            autoSelect: false,
            options: [
                {
                    text: '男性',
                    value: '1'
                },
                {
                    text: '女性',
                    value: '2'
                }
            ]
        },
        {
            xtype: 'numberfield',
            name: 'age',
            label: '年齢'
        },
        {
            xtype: 'textfield',
            name: 'postalCode',
            label: '郵便番号'
        },
        {
            xtype: 'textfield',
            name: 'address',
            label: '住所'
        },
        {
            xtype: 'textareafield',
            name: 'note',
            label: '備考'
        },
        {
            xtype: 'button',
            text: '保存',
            handler: 'onClickSaveButton'
        }
    ]

});

/**
 * ビューコントローラクラス。
 *
 * @class Sample.view.main.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Sample.view.main.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.main',

    /**
     * showイベント時の処理。
     */
    onShow: function () {
        var me = this,
            user = Ext.create('Sample.model.User');

        me.getViewModel().set('user', user);
    },

    /**
     * 保存ボタンクリック時の処理。
     */
    onClickSaveButton: function () {
        var me = this,
            form = me.lookupReference('form');

        if (me.isValid()) {
            // MEMO: エラーがない場合の処理を記述
        } else {
            Ext.Msg.alert('エラー', '入力エラーの箇所があります');
        }
    },

    /**
     * 入力チェック。
     * @returns {boolean} エラー箇所が無い場合はtrue
     */
    isValid: function () {
        var me = this,
            form = me.lookupReference('form'),
            user = form.getRecord(),
            validation,
            firstInvalidField;

        // 一旦エラー表示をクリア
        Ext.each(form.query('field'), function (field) {
            if (Ext.isFunction(field.clearInvalid)) {
                field.clearInvalid();
            }
        });

        user.set(form.getValues());

        validation = user.getValidation();

        if (validation.dirty) {
            Ext.Object.each(validation.data, function (k, v) {
                if (Ext.isString(v)) {
                    var field = form.down('field[name="' + k + '"]');

                    if (field) {
                        field.markInvalid(v);

                        if (!firstInvalidField) {
                            // TODO: 先頭フィールドにフォーカスさせようとしているが、これだと保障されないかも
                            firstInvalidField = field;
                        }
                    }
                }
            });

            if (firstInvalidField) {
                firstInvalidField.focus();
            }

            return false;
        } else {
            return true;
        }
    }
});

/**
 * ビューモデルクラス。
 *
 * @class Sample.view.main.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Sample.view.main.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.main',

    data: {
        /**
         * @cfg {Sample.model.User} ユーザモデル
         */
        user: null
    }
});

バリデーションチェック自体は、モデルのgetValidationメソッドを使った基本通りで、エラーがある場合に、オーバーライドで追加したメソッドを呼び出すようにしています。

面倒になって一部手抜きになっていますが、今回の趣旨とは違うところなのでまあいいかな。あと、おそらくビューコントローラのisValidメソッドは共通化できそう。

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

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

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

なかなか良い感じになりました。