初心者のためのExtJS入門

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

チュートリアル:削除処理の実装(modern)

今回はメモを削除できるようにします。

削除だけだと、あまりにも内容が薄いので、少し細かい部分も実装します。

削除ボタンの設置と削除処理

削除ボタンは一覧画面に設置することにしました。

FontAwesomeアイコンのxマークで削除ボタンを表現しています。

/**
 * メモ一覧リストクラス。
 *
 * @class Memo.view.list.List
 * @extend Ext.List
 */
Ext.define('Memo.view.list.List', {
    extend: 'Ext.List',
    xtype: 'list_list',

    cls: 'list-list',

    itemTpl: [
        '<div class="content">',
            '<h2 class="title">{title}</h2>',
            '<p class="body">{body}</p>',
        '</div>',
        '<div class="action">',
            '<i class="btn-remove fa fa-remove"></i>',
        '</div>'
    ],

    store: 'Memo',

    listeners: {
        itemtap: 'onItemTap'
    }
});

/**
 * メモ一覧ビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * showscreenイベント時の処理。
     */
    onShowScreen: function () {
        Ext.getStore('Memo').load();
    },

    /**
     * 新規作成ボタンタップ時の処理。
     */
    onTapCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * リストitemtapイベント時の処理。
     *
     * @param {Memo.view.list.List} list リスト
     * @param {Number} index インデックス番号
     * @param {Ext.dom.Element} target タップ要素
     * @param {Memo.model.Memo} record メモモデル
     * @param {Ext.event.Event} e イベントオブジェクト
     */
    onItemTap: function (list, index, target, record, e) {
        var me = this,
            el = Ext.fly(e.target),
            store = Ext.getStore('Memo');

        if (el.hasCls('btn-remove')) {
            Ext.Msg.confirm('確認', 'メモを削除します。よろしいですか?', function (btn) {
                if (btn === 'yes') {
                    store.remove(record);
                    store.sync();
                }
            });

            // 行選択を中断するためにfalseを返す
            return false;
        } else {
            me.redirectTo('regist/' + record.getId(), true);
        }
    }
});

ボタンの位置はスタイルシートで調整します。↓のような内容で、sass/src/view/list/List.scssを作成しました(ベンダープレフィックスは手抜きです)。

@charset "UTF-8";

.list-list {
  .x-listitem-body {
    .x-innerhtml {
      display: flex;

      .content {
        flex: 1;
      }

      .action {
        .btn-remove {
          font-size: 1.5em;
        }
      }
    }
  }
}

onItemTapの処理に、削除ボタンをタップされたかどうかの判定と削除処理を追加しています。

判定方法は、classicの時と同じくタップした要素のセレクタ名で行っています。

データの削除は特筆するほどでもないですね(remove => sync)。

最後にonItemTapでretrun falseを追加しています。falseを返すと、itemtapイベントより先の処理を中断することができます。具体的には、行の選択に関する処理まで進まなくなり、タップしたときの背景色が付かなくなります。

改行対応とHTMLエスケープ

今回のネタ不足を補うために、本文に改行含みの文章を保存したときに一覧画面で表示するコードを紹介します。

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

↑のような入力をした場合、一覧画面では次のようになります。

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

もちろんこれでは駄目ですね。そこで↓のようにします。

/**
 * メモ一覧リストクラス。
 *
 * @class Memo.view.list.List
 * @extend Ext.List
 */
Ext.define('Memo.view.list.List', {
    extend: 'Ext.List',
    xtype: 'list_list',

    cls: 'list-list',

    itemTpl: [
        '<div class="content">',
            '<h2 class="title">{[this.br(values.title)]}</h2>',
            '<p class="body">{[this.br(values.body)]}</p>',
        '</div>',
        '<div class="action">',
            '<i class="btn-remove fa fa-remove"></i>',
        '</div>',
        {
            br: function (text) {
                text = Ext.String.htmlEncode(text);
                return text.replace(/\r?\n/g, '<br/>')
            }
        }
    ],

    store: 'Memo',

    listeners: {
        itemtap: 'onItemTap'
    }
});

itemTplは内部的にはExt.XTemplateになるため、その機能を使うことができます。

その機能の一つとして、テンプレート内で使える関数を定義することができます。

その方法は、配列の最後にオブジェクトリテラルを追加し、そこに関数を定義します。すると、{[this.xxx()]}のように記述することで、その関数を呼び出すことが可能です。{[...]}で記述する場合、valuesという予約変数から全ての値を参照できます。

エスケープ処理は、Ext.String.htmlEncodeで用意されているので、それを使います。改行文字を
に置き換えるのは一般的な方法で行っています。

エスケープ + 改行処理を、共通関数として用意しておくと、他の画面でも使えて便利になりますよ。

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

↑のように、きれいに表示されるようになりました。

 

 

 

メモ登録機能としては最低限のことができるようになったので、チュートリアルは今回で終わりにしようと思います。次回からはメモ登録とは関係ない機能を取り上げていこうと思います。

チュートリアル:ルーティングを使って、SPA(シングルページアプリケーション)にする(modern)

今回は、SPA対応のmodern版です。

まあ、classicとほとんど同じなのですが。。。

ルーティングの設定

メインパネルにビューコントローラを設定し、そのビューコントローラにルーティングを定義します。

/**
 * メインパネルクラス。
 *
 * @class Memo.view.main.Main
 * @extend Ext.Panel
 */
Ext.define('Memo.view.main.Main', {
    extend: 'Ext.Panel',
    xtype: 'app_main',

    requires: [
        'Memo.view.main.ViewController'
    ],

    controller: 'app_main',

    layout: 'card'
});

/**
 * メインパネルのビューコントローラクラス。
 * 
 * @class Memo.view.main.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.main.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.app_main',

    requires: [
        'Memo.view.list.Panel',
        'Memo.view.regist.Panel'
    ],

    routes: {
        'list': 'showList',
        'regist': 'showRegist',
        'regist/:id': 'showRegist'
    },

    /**
     * 一覧画面を表示する。
     */
    showList: function () {
        this.switchScreen('list_panel');
    },

    /**
     * 登録画面を表示する。
     * @param {Number} [id] メモID
     */
    showRegist: function (id) {
        var me = this,
            params = {};

        if (Ext.isDefined(id)) {
            params.id = +id;
        }

        me.switchScreen('regist_panel', params);
    },

    /**
     * 画面を切り替える。
     *
     * @param {String} screenXType 画面のxtype
     * @param {Object} [params] パラメータ
     */
    switchScreen: function (screenXType, params) {
        var me = this,
            view = me.getView(),
            screen;

        params = params || {};

        // 画面の存在チェック
        screen = view.down(screenXType);

        if (!screen) {
            // 画面を生成
            screen = Ext.widget(screenXType);

            view.add(screen);
        }

        view.setActiveItem(screen);

        screen.fireEvent('showscreen', params);
    }
});

説明を忘れていましたが、Memo.view.main.Mainは「メインパネル」としています。ビューポートじゃないのか?というと、ビューポートではありません。modernでは、自動的にビューポートが作成されており、Ext.Viewportでグローバルに参照できるようになっています。Memo.view.main.Mainは、ビューポートに自動的に配置されるビューで、大枠のビューとして使うことにしています。ちなみに、この自動生成はapp.jsというファイルに定義されています。

さて、ルーティングについてですが、これはclassicの時とほぼ同じです。異なる箇所ですが、classicではview.getLayout().setActiveItem(screen)だったコードが、view.setActiveItem(screen)となっています。こういうのがclassicとmodernでのAPIの微妙な違いによるものです。

プロキシを変更

localstorageに変更しておきます。

/**
 * メモモデルクラス。
 *
 * @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'
        }
    ],

    validators: {
        title: 'presence'
    }
});

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

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

    model: 'Memo.model.Memo',

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

一覧画面のビューコントローラ作成

ビューコントローラを追加し、一覧画面の処理を実装します。

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

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

    controller: 'list',

    title: 'メモ一覧',

    tools: [
        {
            xtype: 'button',
            iconCls: 'x-fa fa-plus',
            ui: 'action',
            handler: 'onTapCreateButton'
        }
    ],

    items: {
        xtype: 'list_list'
    },

    listeners: {
        showscreen: 'onShowScreen'
    }
});

/**
 * メモ一覧リストクラス。
 *
 * @class Memo.view.list.List
 * @extend Ext.List
 */
Ext.define('Memo.view.list.List', {
    extend: 'Ext.List',
    xtype: 'list_list',

    cls: 'list-list',

    itemTpl: [
        '<h2 class="title">{title}</h2>',
        '<p class="body">{body}</p>'
    ],

    store: 'Memo',

    listeners: {
        itemtap: 'onItemTap'
    }
});

/**
 * メモ一覧ビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * showscreenイベント時の処理。
     */
    onShowScreen: function () {
        Ext.getStore('Memo').load();
    },

    /**
     * 新規作成ボタンタップ時の処理。
     */
    onTapCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * リストitemtapイベント時の処理。
     *
     * @param {Memo.view.list.List} list リスト
     * @param {Number} index インデックス番号
     * @param {Ext.dom.Element} target タップ要素
     * @param {Memo.model.Memo} record メモモデル
     */
    onItemTap: function (list, index, target, record) {
        this.redirectTo('regist/' + record.getId(), true);
    }
});

Memo.view.list.Listのxtypeが残念なことになっていますが、まあ、これで進めましょうw

classicで「click」と名の付いたイベントは、modernでは「tap」となります。Ext.Listで行をタップしたときは、itemtapイベントが発生するので、イベントハンドラの割り当てもitemtapに行います。

ボタンの場合は、classicと同じようにhandlerコンフィグにイベントハンドラを割り当てられますが、内部的にはtapイベント発生時に処理されています。

登録画面のビューコントローラ、ビューモデル作成

/**
 * メモ登録フォームパネルクラス。
 *
 * @class Memo.view.regist.Panel
 * @extend Ext.form.Panel
 */
Ext.define('Memo.view.regist.Panel', {
    extend: 'Ext.form.Panel',
    xtype: 'regist_panel',

    requires: [
        'Memo.view.regist.ViewController',
        'Memo.view.regist.ViewModel'
    ],

    controller: 'regist',
    viewModel: 'regist',

    title: 'メモ登録',

    bind: {
        title: 'メモ登録({labelFormMode})',
        record: '{record}'
    },

    listeners: {
        showscreen: 'onShowScreen'
    },

    tools: [
        {
            xtype: 'button',
            ui: 'action',
            iconCls: 'x-fa fa-check',
            handler: 'onTapSaveButton'
        }
    ],

    bodyPadding: 10,

    items: [
        {
            xtype: 'textfield',
            name: 'title',
            placeHolder: 'タイトルを入力してください'
        },
        {
            xtype: 'textareafield',
            name: 'body',
            placeHolder: '本文を入力してください'
        }
    ]

});

/**
 * メモ登録のビューコントローラクラス。
 *
 * @class Memo.view.regist.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.regist.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.regist',

    /**
     * showscreenイベント時の処理。
     * @param {Object} params パラメータ
     */
    onShowScreen: function (params) {
        var me = this,
            viewModel = me.getViewModel(),
            store = Ext.getStore('Memo'),
            record = null;

        if (Ext.isNumber(params.id)) {
            record = store.getById(params.id);
        }

        if (!record) {
            record = Ext.create('Memo.model.Memo');
        }

        viewModel.set('record', record);
    },

    /**
     * 保存ボタンタップ時の処理。
     */
    onTapSaveButton: function () {
        var me = this,
            store = Ext.getStore('Memo'),
            form = me.getView(),
            record = form.getRecord(),
            validation;

        record.set(form.getValues());

        validation = record.getValidation();

        if (!validation.dirty) {
            if (record.phantom) {
                // 新規
                store.add(record);
            }

            store.sync();

            Ext.Msg.alert('完了', '保存しました', function () {
                me.redirectTo('list', true);
            });
        } else {
            var errorMsg = [];
            Ext.Object.each(validation.data, function (key, value) {
                if (value !== true) {
                    errorMsg.push(key + ':' + value);
                }
            });

            if (errorMsg.length > 0) {
                Ext.Msg.alert('エラー', errorMsg.join('<br>'));
            }
        }
    }

});

/**
 * メモ登録のビューモデルクラス。
 *
 * @class Memo.view.regist.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Memo.view.regist.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.regist',

    data: {
        /**
         * @cfg {Memo.model.Memo} 選択中のメモモデル。
         */
        record: null
    },

    formulas: {
        /**
         * フォームのモード(新規か編集か)のラベルを返す。
         *
         * @param {Function} get
         * @returns {string} フォームのモード(新規か編集か)のラベル
         */
        labelFormMode: function (get) {
            var record = get('record');
            return record ? '編集' : '新規作成';
        }
    }
});

大きく異なる部分は、入力チェックの部分です。

classicでは入力フィールドにバリデーションチェックの定義を設定しますが、modernではモデルのvalidatorsにバリデーションチェックの定義を設定します。

具体的には、モデルのgetValidationの戻り値であるExt.data.Validationのdirtyでエラーがあるかどうかを判定します。

そして、エラーがあった場合の表示方法が乏しいのがmodernの特徴です(ノД`)

classicの場合、エラーのあった入力フィールドにエラーメッセージを表示する機能が標準で備わっていますが、modernでは無いためユーザ自身で実装する必要があります。

先のコードでは、メッセージボックスで簡単にエラー表示しています。

defaultTokenを設定しておく

ここはclassicと同じです。

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

    name: 'Memo',

    stores: [
        'Memo'
    ],

    defaultToken : 'list'
});

最終的に、↓のようになりました。

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

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

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

エラーメッセージがひどい状態ですが、今回はここまで。

次回は削除できるようにします。

チュートリアル:登録画面の作成(modern)

次に登録画面を作成します。

まだビューコントローラとビューモデルは作成せず、ビュー部分だけ作ってみます。

/**
 * メモ登録フォームパネルクラス。
 *
 * @class Memo.view.regist.Panel
 * @extend Ext.form.Panel
 */
Ext.define('Memo.view.regist.Panel', {
    extend: 'Ext.form.Panel',
    xtype: 'regist_panel',

    title: 'メモ登録',

    tools: [
        {
            xtype: 'button',
            ui: 'action',
            iconCls: 'x-fa fa-check'
        }
    ],

    bodyPadding: 10,

    items: [
        {
            xtype: 'textfield',
            name: 'title',
            placeHolder: 'タイトルを入力してください'
        },
        {
            xtype: 'textareafield',
            name: 'body',
            placeHolder: '本文を入力してください'
        }
    ]

});

同じようなUIで作成してみました。↑のように、プロパティ名が異なったり、テキストエリアのxtype名がtextareaでなくtextareafieldだったりと、若干の違いがあります。

また、modernだとExt.form.Panelにはrecordコンフィグがはじめから存在し、データバインディングできるようになっています。

保存ボタンはチェックマークのアイコンにしてみました。

ドキュメントに記載されていますが、iconClsにはFontAwesomeのアイコンを使えます。x-faを付けて、あとはhttp://fontawesome.io/icons/にあるアイコンを指定すればOKです。ただし、ExtJSで自動生成されるFontAwesomeパッケージのバージョンは最新ではないため、使えない(存在しない)アイコンがあることに注意してください。

また、uiというプロパティを指定しています。これでボタンのデザインを指定できます。

指定可能な種類は、http://docs.sencha.com/extjs/6.2.1/modern/Ext.Button.htmlに記載されています。自分で追加することも可能です(そのうち載せる予定)。

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

これで画面作成は完了です。

次回はルーティングを定義し、SPAに対応します。

チュートリアル:一覧画面を作成する(modern)

これからclassicで作った画面の、modern版を作成していきます。

まずはメモ一覧画面です。

モデル、ストアを作成する

ここはclassicのときと同じです。ただし、プロキシを一旦memoryに戻します。

ビューコントローラを作る頃に、またlocalstorageにします。

作成するのはmodern/src/model/Memo.jsとmodern/src/store/Memo.jsになります。

/**
 * メモモデルクラス。
 *
 * @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'
        }
    ]
});

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

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

    model: 'Memo.model.Memo',

    proxy: 'memory',

    data: [
        { id: 1, title: 'タイトル1', body: '本文1' },
        { id: 2, title: 'タイトル2', body: '本文2' },
        { id: 3, title: 'タイトル3', body: '本文3' }
    ]
});

一覧画面を作成する

一覧部分のビューとしては、Ext.Listクラスを使えます。

Ext.ListはExt.dataview.Listの別名です。

classicのExt.view.Viewと同じように使えます。

/**
 * メモ一覧リストクラス。
 *
 * @class Memo.view.list.List
 * @extend Ext.List
 */
Ext.define('Memo.view.list.List', {
    extend: 'Ext.List',
    xtype: 'list_list',
    
    cls: 'list-list',

    itemTpl: [
        '<h2 class="title">{title}</h2>',
        '<p class="body">{body}</p>'
    ],

    store: 'Memo'
});

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

    requires: [
        'Memo.view.list.List'
    ],

    title: 'メモ一覧',

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

/**
 * メインパネルクラス。
 *
 * @class Memo.view.main.Main
 * @extend Ext.Panel
 */
Ext.define('Memo.view.main.Main', {
    extend: 'Ext.Panel',
    xtype: 'app_main',

    requires: [
        'Memo.view.list.Panel'
    ],

    items: [
        {
            xtype: 'list_panel'
        }
    ]
});

itemTplで、ストアの1件分のモデルのデータを表示するHTMLのテンプレート部分を定義します。

ストアには、先に作成したものを指定しています。なので、アプリケーションクラスにストアの定義を追加する必要があります。

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

    stores: [
        'Memo'
    ]
});

これでhttp://localhost:1841にアクセスすると、↓のようになりました。

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

今回はここまで。次回は登録画面を作成してみます。

チュートリアル:モバイル向けの画面を作成してみる

前回までで、メモを残せるアプリケーションがおおよそ完成しました。

しかし、それはあくまでPCブラウザ向けです。

今回からは、モバイル向けの画面を作ってみます。(ちなみに、チュートリアルはモバイル向けまで完成したら終わりにするつもりです)

予備知識

これまではclassicディレクトリを使っていましたが、モバイル向けの場合はmodernディレクトリを使います。

また、ドキュメント(http://docs.sencha.com/extjs/6.2.1/index.html)ですが、右上にある「M」のみを有効にしておけば、modernで使えるクラスを検索できます。

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

classicとmodernでは、ExtJS SDKで使うソースが全く別物です。しかし、クラス名は同じだったりするので、ドキュメントを調べる場合などは注意が必要です。

modernで使えるクラスは、昔のSencha Touch2がベースになっています。そのため、classicとコードの記述方法が異なる部分が多々あります。

シンプルな画面を作成する

まずはApplication.jsをmodern/srcに作成します。

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

次にmodern/src/view/main/Main.jsを変更します。Main.jsはアプリケーション作成時に自動的に作成されたファイルですね。

/**
 * ビューポートクラス。
 *
 * @class Memo.view.main.Main
 * @extend Ext.Panel
 */
Ext.define('Memo.view.main.Main', {
    extend: 'Ext.Panel',
    xtype: 'app_main',

    title: 'タイトル',

    html: 'テキスト'
});

ビルドとサーバ起動

開発時のビルドには、↓のようにsencha app watchのあとに「modern」と付与する必要があります。

sencha app watch modern

途中で「[INF] Processing Build Descriptor : modern」と表示されると、modernのビルドになっていることを示しています。

画面を表示してみる

あとは画面を表示してみます。ブラウザのUser Agentに気を付けてください。

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

これで準備完了です。

チュートリアル:ストアのプロキシを変更する

今回はストアのプロキシをローカルストレージに変更します。

これで、一応データを保存できるようになります。ローカルストレージにですが。。。(サーバ側が無いのでやむなし)

ストアのプロキシ変更

まずはストアのプロキシを変更して、ローカルストレージを使用するようにします。

/**
 * メモストアクラス。
 *
 * @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'
    }
});

idはローカルストレージで使用する識別子になります。画面を表示してからDeveloper toolsで見てみると、下記のようにmemoというキーが自動的に用意されていることが分かります。

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

保存処理をプロキシに合わせて変更

ドキュメント(http://docs.sencha.com/extjs/6.2.1/classic/Ext.data.proxy.LocalStorage.html)を見ると分かりますが、ストアのsyncメソッドを呼び出すことでローカルストレージに反映されるので、保存処理を修正する必要があります。

/**
 * メモ登録のビューコントローラクラス。
 *
 * @class Memo.view.regist.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.regist.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.regist',

    /**
     * showscreenイベント時の処理。
     * @param {Object} params パラメータ
     */
    onShowScreen: function (params) {
        var me = this,
            viewModel = me.getViewModel(),
            store = Ext.getStore('Memo'),
            record = null;

        if (Ext.isNumber(params.id)) {
            record = store.getById(params.id);
        }

        viewModel.set('record', record);
    },

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

        if (form.isValid()) {
            var record = form.getRecord();

            record.set(form.getValues());

            if (record.phantom) {
                // 新規
                store.add(record);
            }

            store.sync();

            Ext.Msg.alert('完了', '保存しました', function () {
                me.redirectTo('list');
            });
        }
    }

});

ついでにリファクタリングしてみました。(だいぶコードがすっきりしましたよ)

syncを呼ぶまでは、メモリ上で変更しただけになります。

syncを呼ぶことで初めてローカルストレージに書き込まれることに注意しましょう。

一覧画面の表示時にデータをロードする

あとは一覧画面を表示したときに、ローカルストレージからデータをロードする必要があります。

こういうときはshowscreenイベント時に処理できるようにしたのが便利に感じます。

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

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

    controller: 'list',

    layout: 'fit',

    bodyPadding: 15,

    scrollable: true,

    listeners: {
        showscreen: 'onShowScreen'
    },

    title: 'メモ一覧',

    tools: [
        {
            xtype: 'button',
            text: '新規作成',
            handler: 'onClickCreateButton'
        }
    ],

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

/**
 * メモ一覧データビュークラス。
 *
 * @class Memo.view.list.View
 * @extend Ext.view.View
 */
Ext.define('Memo.view.list.View', {
    extend: 'Ext.view.View',
    xtype: 'list_dataview',

    cls: 'list-dataview',

    tpl: [
        '<tpl for=".">',
            '<div class="item">',
                '<h3 class="title">{title}</h3>',
                '<p class="body">{body}</p>',
            '</div>',
        '</tpl>'
    ],

    itemSelector: 'div.item',

    store: 'Memo',

    emptyText: 'メモは登録されていません。新しいメモは「新規作成」ボタンから作成できます。',

    listeners: {
        itemdblclick: 'onItemDblClick'
    }

});

/**
 * メモ一覧のビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * showscreenイベント時の処理。
     */
    onShowScreen: function () {
        Ext.getStore('Memo').load();
    },

    /**
     * 新規作成ボタンクリック時の処理。
     */
    onClickCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * データビューitemdblclickイベント時の処理。
     *
     * @param {Memo.view.list.View} dataview データビュー
     * @param {Memo.model.Memo} record メモモデル
     */
    onItemDblClick: function (dataview, record) {
        this.redirectTo('regist/' + record.getId(), true);
    }

});

ついでにデータビューのemptyTextプロパティを設定して、メモが未登録のときにテキストを表示するようにしました。

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

試しに登録してみます。

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

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

登録してみると、ローカルストレージにmemo-1というキーのデータが追加されたのがわかります。ブラウザをリロードしてもデータが残るようになりました。ちなみにmemo-1はid=1のデータとなります。もう1件登録してみるとmemo-2が追加されるのが分かります。

削除できるようにする

メモを削除できるように削除ボタンを追加します。

一覧画面に1件分を削除するボタンと全て削除するボタンの2種類用意していこうと思います。

/**
 * メモ一覧データビュークラス。
 *
 * @class Memo.view.list.View
 * @extend Ext.view.View
 */
Ext.define('Memo.view.list.View', {
    extend: 'Ext.view.View',
    xtype: 'list_dataview',

    cls: 'list-dataview',

    tpl: [
        '<tpl for=".">',
            '<div class="item">',
                '<i class="btn-remove fa fa-times"></i>',
                '<h3 class="title">{title}</h3>',
                '<p class="body">{body}</p>',
            '</div>',
        '</tpl>'
    ],

    itemSelector: 'div.item',

    store: 'Memo',

    emptyText: 'メモは登録されていません。新しいメモは「新規作成」ボタンから作成できます。',

    listeners: {
        itemclick: 'onItemClick',
        itemdblclick: 'onItemDblClick'
    }

});

/**
 * メモ一覧のビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * showscreenイベント時の処理。
     */
    onShowScreen: function () {
        Ext.getStore('Memo').load();
    },

    /**
     * 新規作成ボタンクリック時の処理。
     */
    onClickCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * 全て削除ボタンクリック時の処理。
     */
    onClickRemoveAllButton: function () {
        Ext.Msg.confirm(
            '確認',
            '全てのメモを削除します。よろしいですか?',
            function (btn) {
                if (btn === 'yes') {
                    var store = Ext.getStore('Memo');

                    store.removeAll();
                    store.sync();
                }
            }
        );
    },

    /**
     * データビューitemclickイベント時の処理。
     *
     * @param {Memo.view.list.View} dataview データビュー
     * @param {Memo.model.Memo} record メモモデル
     * @param {HTMLElement} html アイテムのHTML要素
     * @param {number} index インデックス番号
     * @param {Ext.event.Event} e イベントオブジェクト
     */
    onItemClick: function (dataview, record, html, index, e) {
        var el = Ext.fly(e.target);

        if (el.hasCls('btn-remove')) {
            e.stopEvent();

            Ext.Msg.confirm(
                '確認',
                'メモを削除します。よろしいですか?',
                function (btn) {
                    if (btn === 'yes') {
                        var store = Ext.getStore('Memo');

                        store.remove(record);
                        store.sync();
                    }
                }
            );
        }
    },

    /**
     * データビューitemdblclickイベント時の処理。
     *
     * @param {Memo.view.list.View} dataview データビュー
     * @param {Memo.model.Memo} record メモモデル
     */
    onItemDblClick: function (dataview, record) {
        this.redirectTo('regist/' + record.getId(), true);
    }

});
@charset "UTF-8";

.list-dataview {
  .item {
    position: relative;
    display: inline-block;
    width: 200px;
    height: 150px;
    background-color: #fff;
    cursor: pointer;
    outline: none !important;
    margin-right: 10px;
    margin-bottom: 10px;
    @include box-shadow(0 0 2px #aaa);

    .title {
      background-color: #5fa2dd;
      color: #fff;
      padding: 5px;
      margin: 0;
    }

    .body {
      padding: 5px;
      margin: 0;
    }

    &.x-item-selected {
      .title {
        background-color: darken(#5fa2dd, 20%);
      }
    }

    .btn-remove {
      position: absolute;
      top: 3px;
      right: 3px;
      color: #fff;
      font-size: 20px;

      &:hover {
        color: #eee;
      }
    }
  }
}

基本的には、ストアから削除してからsyncメソッドを実行しています。

1件の削除では、FontAwesomeのアイコンで削除ボタンを設置してみました(ExtJSのパッケージとして、はじめからfont-awesomeが用意されています)。データビューのitemclickイベントで削除ボタンをクリックしていたら削除するようにしています。

onItemClickメソッドでは、Ext.flyを使っています。このメソッドの戻り値はExt.dom.Elementですが、これはHTMLElement、いわゆるDOMのラッパークラスです。Ext.dom.Elementの形式でDOMにアクセスすると、いろいろ便利なメソッドを使えるようになります。今回は、Ext.flyを使っていますが、Ext.getを使うこともできます。違いですが、イベントの割り当てなどが必要な場合はExt.getを使う必要があります。Ext.flyは一時的な参照の場合に使うようにドキュメントに記載があります。これはExt.flyはいろいろな箇所で呼ばれており、別の箇所で呼ばれると、その前のDOMへの参照やイベントは破棄されるようになっているためです。その分、Ext.flyのほうが処理は軽いという特性があります。

あと、e.stopEventですが、内部的にはpreventDefaultとstopPropagationを実行してくれます。

これでデータをローカルストレージに残せるようになり、また、削除もできるようにできました。

チュートリアル:ルーティングを使って、SPA(シングルページアプリケーション)にする(2)

今回はSPAの続きです。

ルーティングで画面を切り替える部分は実装しましたが、残りの部分を修正していきます。

新規作成、編集のURLパターンを分ける

登録画面のパターンとしては、新規作成と編集の2つがあります。

そこで、下記のようにしてみます。

#regist => 新規登録
#regist/(メモID) => 編集

メモモデルにIDを追加して(メモID)、編集はそのIDを使って行います。

/**
 * メモモデルクラス。
 *
 * @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'
        }
    ]
});

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

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

    model: 'Memo.model.Memo',

    proxy: 'memory',

    data: [
        { id: 1, title: 'タイトル1', body: '本文1' },
        { id: 2, title: 'タイトル2', body: '本文2' },
        { id: 3, title: 'タイトル3', body: '本文3' }
    ]
});

ビューコントローラに編集用のルーティングを追加してみます。

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

    requires: [
        'Memo.view.list.Panel',
        'Memo.view.regist.Panel'
    ],

    routes: {
        'list': 'showList',
        'regist': 'showRegist',
        'regist/:id': 'showRegist'
    },

    /**
     * 一覧画面を表示する。
     */
    showList: function () {
        this.switchScreen('list_panel');
    },

    /**
     * 登録画面を表示する。
     * @param {String} [id] メモID
     */
    showRegist: function (id) {
        var me = this,
            params = {};

        if (Ext.isDefined(id)) {
            params.id = +id;
        }

        me.switchScreen('regist_panel', params);
    },

    /**
     * 画面を切り替える。
     *
     * @param {String} screenXType 画面のxtype
     * @param {Object} [params] パラメータ
     */
    switchScreen: function (screenXType, params) {
        var me = this,
            view = me.getView(),
            screen;

        params = params || {};

        // 画面の存在チェック
        screen = view.down(screenXType);

        if (!screen) {
            // 画面を生成
            screen = Ext.widget(screenXType);

            // ビューポートに追加
            view.add(screen);
        }

        view.getLayout().setActiveItem(screen);

        screen.fireEvent('showscreen', params);
    }

});

すこしメソッド名を変えてます(これは私の感覚的なものですw)。

さて、編集用に下記を追加しました。

'regist/:id': 'showRegist'

:idという部分がメモIDに対応します。このようにコロンを付けて定義することで、動的な値に対応することができるようになっています。

:idの値は、showRegistの引数に渡されます。:idのような値が複数ある場合、第1引数、第2引数、・・・と順番に渡されます。

あとはshowRegistメソッド内で受け取るようにしました。

最終的には下記の部分でパラメータとしてメモIDが渡っていきます。

screen.fireEvent('showscreen', params);

fireEventというのは、イベントを発生させるためのメソッドです。これまでlistenersでイベント発生時の処理を設定してきましたが、自前のイベントを追加したい場合などは、このfireEventを使ってイベント発生させます。

今回は、画面が切り替わったらshowscreenというイベントを発火させることで、SPAとして画面が切り替わった場合にはこのイベントが発生するというルールを定義付けました。

一覧画面から登録画面へ遷移する

一覧画面から登録画面に遷移する部分を実装してみます。

イメージとしては、↓のようなかんじです。

一覧画面に新規作成ボタンを設定して、それをクリックしたら新規登録画面へ遷移する。
メモをダブルクリックしたら編集画面に遷移する。

まずは一覧画面です。

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

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

    controller: 'list',

    layout: 'fit',

    bodyPadding: 15,

    scrollable: true,

    title: 'メモ一覧',

    tools: [
        {
            xtype: 'button',
            text: '新規作成',
            handler: 'onClickCreateButton'
        }
    ],

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

/**
 * メモ一覧データビュークラス。
 *
 * @class Memo.view.list.View
 * @extend Ext.view.View
 */
Ext.define('Memo.view.list.View', {
    extend: 'Ext.view.View',
    xtype: 'list_dataview',

    cls: 'list-dataview',

    tpl: [
        '<tpl for=".">',
            '<div class="item">',
                '<h3 class="title">{title}</h3>',
                '<p class="body">{body}</p>',
            '</div>',
        '</tpl>'
    ],

    itemSelector: 'div.item',

    store: 'Memo',

    listeners: {
        itemdblclick: 'onItemDblClick'
    }

});

/**
 * メモ一覧のビューコントローラクラス。
 *
 * @class Memo.view.list.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.list.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.list',

    /**
     * 新規作成ボタンクリック時の処理。
     */
    onClickCreateButton: function () {
        this.redirectTo('regist', true);
    },

    /**
     * データビューitemdblclickイベント時の処理。
     *
     * @param {Memo.view.list.View} dataview データビュー
     * @param {Memo.model.Memo} record メモモデル
     */
    onItemDblClick: function (dataview, record) {
        this.redirectTo('regist/' + record.getId(), true);
    }

});

新規作成ボタンを設置してクリックしたらonClickCreateButtonが呼ばれるようにしました。あと、データビューのitemdblclickイベントが発生したらonItemDblClickが呼ばれるようにしています。

で、それらのメソッドではredirectToを呼び出すようにしています。これによって指定されたハッシュにURLを切り替えることができます。

なお、第2引数に設定しているtrueですが、これは強制するかどうかを示すフラグです。現在のURLが、既にredirecToで指定されたURLになっている場合、第2引数がfalseだとルーティング部分の処理が発生しません。同じURLであったとしても画面切り替えの処理をしてほしい場合はtrueを設定します。私は基本的にtrueを設定しておけば良いと思っています。

登録画面の処理を実装

あとは登録画面の実装です。

ここでは編集の場合は画面表示のタイミングで、メモのデータをロードするというかんじにします。

他は以前とだいたい同じです(プロパティ名とかは適宜変えてます)。

/**
 * メモ登録フォームパネルクラス。
 *
 * @class Memo.view.regist.Panel
 * @extend Ext.form.Panel
 */
Ext.define('Memo.view.regist.Panel', {
    extend: 'Ext.form.Panel',
    xtype: 'regist_panel',

    requires: [
        'Memo.view.regist.ViewController',
        'Memo.view.regist.ViewModel'
    ],

    controller: 'regist',
    viewModel: 'regist',

    bodyPadding: 15,

    title: 'メモ入力フォーム',

    config: {
        /**
         * @cfg {Memo.model.Memo} メモモデル。
         */
        record: null
    },

    bind: {
        title: 'メモ入力フォーム({labelFormMode})',
        record: '{record}'
    },

    listeners: {
        showscreen: 'onShowScreen'
    },

    defaults: {
        anchor: '100%'
    },

    items: [
        {
            name: 'title',
            xtype: 'textfield',
            emptyText: 'タイトルを入力してください',
            allowBlank: false
        },
        {
            name: 'body',
            xtype: 'textarea',
            emptyText: '本文を入力してください'
        },
        {
            xtype: 'button',
            text: '保存',
            handler: 'onClickSaveButton'
        }
    ],

    /**
     * recordコンフィグ設定時の処理。
     * @param {Memo.model.Memo} record メモモデル
     */
    setRecord: function (record) {
        var me = this;

        me.loadRecord(record);

        me.callParent(arguments);
    }

});

/**
 * メモ登録のビューコントローラクラス。
 *
 * @class Memo.view.regist.ViewController
 * @extend Ext.app.ViewController
 */
Ext.define('Memo.view.regist.ViewController', {
    extend: 'Ext.app.ViewController',
    alias: 'controller.regist',

    /**
     * showscreenイベント時の処理。
     * @param {Object} params パラメータ
     */
    onShowScreen: function (params) {
        var me = this,
            viewModel = me.getViewModel(),
            store = Ext.getStore('Memo'),
            record = null;

        if (Ext.isNumber(params.id)) {
            record = store.getById(params.id);
        }

        if (!record) {
            record = Ext.create('Memo.model.Memo');
        }

        viewModel.set('record', record);
    },

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

        if (form.isValid()) {
            var record = form.getRecord();

            if (record.phantom) {
                // 新規
                record = store.add(form.getValues())[0];
            } else {
                // 更新
                record.set(form.getValues());
            }

            record.commit();

            Ext.Msg.alert('完了', '保存しました', function () {
                me.redirectTo('list');
            });
        }
    }

});

/**
 * メモ登録のビューモデルクラス。
 *
 * @class Memo.view.regist.ViewModel
 * @extend Ext.app.ViewModel
 */
Ext.define('Memo.view.regist.ViewModel', {
    extend: 'Ext.app.ViewModel',
    alias: 'viewmodel.regist',

    data: {
        /**
         * @cfg {Memo.model.Memo} 選択中のメモモデル。
         */
        record: null
    },

    formulas: {
        /**
         * フォームのモード(新規か編集か)のラベルを返す。
         *
         * @param {Function} get
         * @returns {string} フォームのモード(新規か編集か)のラベル
         */
        labelFormMode: function (get) {
            var record = get('record');
            return record ? '編集' : '新規作成';
        }
    }
});

showscreenイベント時にonShowScreenが実行されるようにして、その中でデータを取得するようにしています。他の画面でも、画面表示時の処理は同じように定義できるようになりました(私はよくこのようにしておきます)。

他は大きく変えていません。保存したときに完了メッセージを出して、一覧画面に戻るようにしたぐらいですかね。ここではredirectTo('list')で戻るようにしていますが、history.backでも良いと思います。

defaultTokenを設定しておく

最後に、Ext.app.ApplicationのdefaultTokenを設定しておきます。

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

    stores: [
        'Memo'
    ],

    defaultToken : 'list'
});

これを付けておくと、http://localhost:1841にアクセスした場合にhttp://localhost:1841/#listにリダイレクトしてくれます。デフォルトのルートを設定できるわけです。

最終的に、↓のようになりました(スタイルは調整しています)。

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

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

どうにか、当初の予定はクリアできているかと思います。

ちなみに、実際の案件ではこれだけでは駄目で、ルーティングに存在しないURLにアクセスされた場合への対応を入れたり、もっと汎用的なルーティング処理に変更したりと考えることは増えていきます。正直言って手間のかかる対応です。しかし、SPAにすることで、ユーザの操作感が各段に向上するのでお勧めです。

次回はストアのプロキシを変更するのと、削除できるようにする予定です。