angular-mocks でモックが返すデータを動的に決める

angular-mocks をスタブとして使ってフロントエンドを開発していて、 ずっと

angular.module("sampleApp").run(["$httpBackend", function($httpBackend) {
  $httpBackend.whenGET("/users").respond([
    { id: 1, name: "香川" },
    { id: 2, name: "本田" },
    { id: 3, name: "長友" }
  ]);
}]);

という感じで決まったデータを返していた。

だけど、ソースコードを読んでいたら

angular.module("sampleApp").run(["$httpBackend", function($httpBackend) {
  var users = [
    { id: 1, name: "香川" },
    { id: 2, name: "本田" },
    { id: 3, name: "長友" }
  ];
  $httpBackend.whenGET("/users").respond(function() {
    return [200, users, {}];
  });
}]);

という風にして、動的に返すデータを変更できることに、今さら気付いた。 関数が返す配列にデータだけでなく、ステータスコードやヘッダーを含めるのがポイント。

以前試しに respond に function を渡したことがあって、そのときはデータだけ返していたから上手くいかなかったのか。 諦めずにソースコードまで追っていれば、もっと早く気付けたと思うと悔しい。

ng-templat​e でテンプレートを HTML に埋め込む

script タグの type に text/ng-template を指定したら、 AnguarJS が中のテキストを $templateCache にキャッシュしてくれる。

そして、ルーターでテンプレートの URL を指定するとき、 その script タグの id にすると $templateCache にキャッシュされているテンプレートが使える。

 <!DOCTYPE html>
 <html ng-app="SampleApp">
   <head>
     <meta charset="utf-8">
     <title>SampleApp</title>
   </head>
   <body ng-controller="MainCtrl">
       <ul class="nav">
         <li><a href="#/">Home</a></li>
         <li><a href="#/tags">Tags</a></li>
       </ul>
       <div class="main" ng-view></div>
     </div>

     <!-- type を text/ng-template にすると $templateCache にキャッシュされる -->
     <script id="home_template" type="text/ng-template">
       <h2>Timeline</h2>
       <ul ng-repeat="activity in activities">
         <li>
           <span ng-bind="activity.account"></span> - <span ng-bind="activity.action"></span>
         </li>
       </ul>
     </script>
     <script id="tags_template" type="text/ng-template">
       <h2>Tags</h2>
       <ul ng-repeat="tag in tags">
         <li ng-bind="tag.name"></li>
       </ul>
     </script>

     <script src="js/angular.js"></script>
     <script src="js/angular-route.js"></script>
     <script>
       window.App = angular.module("SampleApp", ["ngRoute"]);

       // templateUrl には script タグで書いたテンプレートの id を指定する
      App.config(["$routeProvider", function($routeProvider) {
           $routeProvider.
             when("/", {
               controller: "HomeCtrl",
               templateUrl: "home_template"
             }).
             when("/tags", {
               controller: "TagsCtrl",
               templateUrl: "tags_template"
             })
       }]);

       App.controller("MainCtrl", ["$scope", function($scope) {
       }]);

       App.controller("HomeCtrl", ["$scope", function($scope) {
           $scope.activities = [
             { account: "foo", action: "create" },
             { account: "bar", action: "delete" }
           ];
       }]);

       App.controller("TagsCtrl", ["$scope", function($scope) {
           $scope.tags = [
             { name: "hoge" },
             { name: "fuga" }
           ];
       }]);
     </script>
   </body>
 </html>

ページ数の少ない Single Page Application なら、これでテンプレートを分けて書けばよさそうだな。

backbone.stickit を使って Backbone でデータバインディング

最近また AngularJS が盛り上がってる気がする。 AngularJS のデータバインディングは魅力的だけど、自分は Backbone 派。 でも Backbone でもデータバインディング使いたい。 そこで New York Times 製の Backbone プラグイン backbone.stickit を試してみた。

基本的な使い方

ビューでバインディングを宣言。 CSS セレクタとモデルの属性名をマッピングする。

var TaskView = Backbone.View.extend({
  template: _.template($("#task_template").html()),

  // バインディングの宣言
  bindings: {
    ".js-task-name": "name"
  },

  render: function() {
    this.$el.html(this.template({}));

    // テンプレートを描画したあと呼び出す
    this.stickit();
    return this;
  }
});

レンダリングしたあと stickit() を呼ぶと、バインディングが適用される。

DOM 要素の表示/非表示

モデルの属性が変更されたとき、 バインドしている DOM 要素を表示するかどうか指定できる。

bindings: {
  ".js-edit-view": {
    observe: "editing",
    // editing が true のとき表示
    visible: function(val, options) {
      return !!val;
    }
  }
}

One way バインディング

input にモデルの属性をバインドして表示したいけど、 編集されても即時にモデル反映してほしくないとき、 One way バインディングが使える。

bindings: {
  ".js-name-input": {
    observe: "name",
    updateModel: false  // モデルを更新しない
  }
}

複数の属性に依存

「firstName と lastName のどちらかが変更されたら表示を更新する」みたいに、 複数の属性とバインドすることもできる。

bindings: {
  ".js-name-input": {
    observe: ["name" "editing"],
    onGet: function(values) {
      return values[0];
    }
  }
}

TODO アプリ

最後に TODO アプリのサンプルを backbone.stickit を使って書いてみた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>stickit sample</title>
  </head>
  <body>
    <h1>Todo</h1>
    <div id="main">
    </div>
    <div id="new_task">
      <input class="js-name-input" type="text"/>
      <button class="js-add-button">Add</button>
    </div>

    <!--タスク描画用テンプレート-->
    <script id="task_template" type="text/template">
      <div class="js-normal-view">
        <input class="js-completed-input", type="checkbox">
          <span class="js-task-name js-task-uncompleted"></span>
          <del class="js-task-completed"><span class="js-task-name"></span></del>
        </input>
        <a class="js-edit-task" href="javascript:void(0);">[edit]</a>
      </div>

      <!--その場編集用 DOM 要素-->
      <div class="js-edit-view">
        <input class="js-name-input" type="text"/>
        <a class="js-save-task" href="javascript:void(0);">[save]</a>
        <a class="js-delete-task" href="javascript:void(0);">[del]</a>
        <a class="js-cancel-task" href="javascript:void(0);">[cancel]</a>
      </div>
    </script>

    <script type="text/javascript" src="jquery-1.9.1.js"></script>
    <script type="text/javascript" src="underscore.js"></script>
    <script type="text/javascript" src="backbone.js"></script>
    <script type="text/javascript" src="backbone.stickit.js"></script>
    <script>
    // タスク
    var Task = Backbone.Model.extend({
      defaults: {
        name: "",
        completed: false,
        editing: false
      },
      beginEdit: function() {
        this.set("editing", true);
      },
      endEdit: function() {
        this.set("editing", false);
      },
      destroy: function() {
        this.trigger("destroy", this);
      }
    });

    // タスクのコレクション
    var TaskList = Backbone.Collection.extend({
      model: Task,

      initialize: function() {
        this.on("add", this.onAdd, this);
      },

      onAdd: function(model) {
        model.on("destroy", this.onDestroy, this);
      },

      onDestroy: function(model) {
        model.off("add", this.onAdd);
        model.off("destroy", this.onDestroy);
        this.remove(model);
      }
    });

    // タスクを表示するビュー
    var TaskView = Backbone.View.extend({
      template: _.template($("#task_template").html()),

      // バインディングの設定
      bindings: {
        // タスク名を表示する
        ".js-task-name": "name",

        // タスクの完了・未完了をチェックボックスで切り替える
        ".js-completed-input": "completed",

        // その場編集モードを切り替えたとき input の値をリセットする
        ".js-name-input": {
          observe: ["name", "editing"],
          onGet: function(values) {
            return values[0];
          },
          updateModel: false
        },

        // 未完了タスク名表示用 DOM 要素の表示を切り替える
        ".js-task-uncompleted": {
          observe: "completed",
          visible: function(val, options) {
            return !val;
          }
        },

        // 完了したタスクの打ち消し線の表示を切り替える
        ".js-task-completed": {
          observe: "completed",
          visible: function(val, options) {
            return !!val;
          }
        },

        // 通常モード用要素の表示を切り替える
        ".js-normal-view": {
          observe: "editing",
          visible: function(val, options) {
            return !val;
          }
        },

        // その場編集モード用要素の表示を切り替える
        ".js-edit-view": {
          observe: "editing",
          visible: function(val, options) {
            return !!val
          }
        }
      },

      events: {
        "click .js-edit-task": "onEdit",
        "click .js-save-task": "onSave",
        "click .js-delete-task": "onDelete",
        "click .js-cancel-task": "onCancel"
      },

      // input の値を取り出してモデルを更新
      onSave: function(e) {
        var name = this.$el.find(".js-name-input").val();
        this.model.set("name", name);
        this.model.endEdit();
      },

      onDelete: function(e) {
        this.model.destroy();
      },

      onEdit: function(e) {
        this.model.beginEdit();
      },

      onCancel: function(e) {
        this.model.endEdit();
      },

      initialize: function() {
          this.model.on("destroy", function(model) {
              this.remove();
          }, this);
      },

      render: function() {
        var html = this.template({});
        this.$el.html(html);
        this.stickit();
        return this;
      }
    });

    // タスクの一覧を表示するビュー
    var TaskListView = Backbone.View.extend({
      initialize: function() {
        this.collection.on("add", this.addOne, this);
      },

      addOne: function(task) {
        var view = new TaskView({ model: task });
        view.render();
        this.$el.append(view.el);
      },

      render: function() {
        this.$el.empty();
        this.collection.each(this.addOne, this);
        return this;
      }
    });

    // 登録フォーム
    var TaskFormView = Backbone.View.extend({
        el: $("#new_task"),

        bindings: {
          ".js-name-input": "name"
        },
        events: {
          "click .js-add-button": "onAdd"
        },

        initialize: function() {
          this.model = new Task();
        },

        onAdd: function(e) {
          var task = this.model.clone();
          this.collection.add(task);
          this.model.set("name", "");
        },

        render: function() {
          this.stickit();
          return this;
        }
    });

    // ルーター
    // エントリポイントでもある
    var MainRouter = Backbone.Router.extend({
      routes: {
        "": "index"
      },

      initialize: function() {
        this.taskList = new TaskList();
        this.taskList.add({ name: "foo" });
        this.taskList.add({ name: "bar", completed: true });

        this.taskForm = new TaskFormView({ collection: this.taskList });
        this.taskForm.render();
      },

      index: function() {
        var view = new TaskListView({ collection: this.taskList });
        view.render();
        $("#main").html(view.el);
      }
    });

    $(function() {
      window.router = new MainRouter();
      Backbone.history.start();
    });
    </script>
  </body>
</html>

その場編集機能を結構無理やり実装したため、テンプレートがカオスになっている。 もっと良いやり方がありそうだけど思いつかなかった。

レーダーチャートを描画したかったので Chart.js を試してみた

レーダーチャートを表示したいんだけど、 これまで Rails で使ってきた lazy_high_chart はどうも対応していないっぽい。

最近、JavaScript ライブラリをラップした gem を使うことに抵抗が出てきたので、 lazy_high_chart を使うのはやめて、他のライブラリを使うことにした。

チャートライブラリでは有名どころの『Chart.js』がレーダーチャートに対応していたので、 こいつを試してみよう。

レーダーチャートを描画するサンプルを描いてみた。 パイチャートや棒チャートなどにも対応してるけど、今回は使う予定ないんで省略。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>ChartSample</title>
  </head>
  <body>
    <!--レーダーチャートを描画する Canvas-->
    <canvas id="canvas" width="300px" height="300px">
    </canvas>

    <script src="jquery-2.0.1.js"></script>
    <script src="Chart.js"></script>
    <script>
      $(function() {
        // レーダーチャートで表示するデータを用意
        var radarChartData = {
          labels: ["スタミナ", "スピード", "テクニック", "パワー", "メンタル"],
          datasets: [
            {
              fillColor: "rgba(151,187,205,0.5)", // 線で囲まれた部分の色
              strokeColor: "rgba(151,187,205,1)", // 線の色
              pointColor: "rgba(151,187,205,1)",  // 点の色
              pointStrokeColor: "#fff",           // 点を囲む線の色
              data: [7,8,10,6,10]
            }
          ]
        };

        // Canvas にレーダーチャートを描画
        var canvas = document.getElementById("canvas");
        var context = canvas.getContext("2d");
        var chart = new Chart(context);
        var rader = chart.Radar(radarChartData, {
          scaleShowLabels: true,  // 目盛を表示
          pointLabelFontSize : 10 // ラベルを表示
        });
      });
    </script>
  </body>
</html>

ブラウザで表示してみる。

f:id:griefworker:20130724190519p:plain

これくらいシンプルに使えるライブラリなら、わざわざ gem で導入する必要ないな。 Bower で入れれば済む。 Rails で使うときにヘルパーくらいは書くかもしれないけど。

Testem + Mocha + Chai で JavaScript のテスト

Rails アプリの JavaScript のテストは、 Konacha 使うのをやめて、Mocha を直接使っている。

Mocha は HTML サポートしているんで、 TestRunner.html 作ってブラウザで表示すればテストが実行され、 お手軽だ。

ただ、複数のブラウザでテストする場合、ブラウザを起動してまわらないといけない。 アプリやテストのコードを修正したら、当然ブラウザを更新して以下略。 ちょっとメンドイ。

メンドイと思ったときがツールの入れどき。 最近いろんなブログで見かけたテストランナー、Testem を導入することに決めた。

Testem インストール

Testem は Node で動くので、npm でインストール。

npm install -g testem

設定ファイル作成

プロジェクトルートに testem.json を作成し、Testem の設定を記述した。

{
  "framework": "mocha",
  "src_files": [
    "vendor/assets/components/underscore/underscore.js",
    "vendor/assets/components/backbone/backbone.js",
    "vendor/assets/components/backbone_rails_sync/backbone_rails_sync.js",
    "vendor/assets/components/hogan/web/builds/2.0.0/hogan-2.0.0.min.js",
    "vendor/assets/components/chai/chai.js",
    "public/assets/template.js",
    "public/assets/application.js",
    "spec/javascripts/spec_helper.js",
    "public/assets/tests.js"
  ],
  "launch_in_dev": [
    "Chrome"
  ]
}

使うテスティングフレームワークは Mocha。 Mocha にはアサーションライブラリがついていないので、Chai を一緒に使っている。

src_files に指定したファイルは、Testem が自動生成する TestRunner.html に埋め込まれる。 Mocha は Testem が提供してくれるので、それ以外のライブラリやテストコードを記述する必要がある。

あと、launch_in_dev で、Chrome が自動で起動するように指定。

テスト実行

ターミナルで

testem

を実行すると、Web ブラウザが自動で起動し、テストが実行された。

f:id:griefworker:20130722195307p:plain

ターミナルにもテスト結果が出力されていた。

f:id:griefworker:20130722195433p:plain

コードを修正して保存すると、Testem がそれを検知してブラウザをリロードしてくれる。 テストを書くのが捗るな。

Grunt で Hogan.js のテンプレートをコンパイル

CoffeeScript と Sass のコンパイルを Grunt で行うようにしたら、 ついでに Hogan.js のテンプレートもコンパイルしたくなるのは仕方ないよね。 人として。

Grunt のタスクをインストール

Grunt の contrib タスクには Hogan.js 用のタスクがあるので、npm でインストール。

npm install grunt-contrib-hogan

EJS から Hogan.js に移行した理由の1つが、このタスクの存在だったり。

Gruntfile 修正

module.exports = function(grunt) {
  grunt.initConfig({
    hogan: {
      publish: {
        options: {
          namespace: "HoganTemplates",
          defaultName: function(filename) {
            return filename.split(".")[0];
          }
        },
        files: {
          "public/assets/template.js": [
            "app/assets/javascripts/backbone/templates/**/*.mustache"
          ]
        }
      }
    }
  });

  grunt.loadNpmTasks("grunt-contrib-hogan");
};

namespace は、JavaScript/CoffeeScript からテンプレートにアクセスするときに使うオブジェクトの名前。HoganTemplates にしてみた。

defaultName は、HoganTemplates からテンプレートを取得するとき指定するキーの命名規則。

いざコンパイル

grunt hogan

を実行すると Hogan.js のテンプレートをコンパイルして、 public/assets/templates.js を出力できた。

Rails のアセットパイプラインから Grunt に移行する準備が着々と進んでいるな。

Grunt で CoffeeScript と Sass をコンパイル

最近、Rails の CoffeeScript や Sass のコンパイルをアセットパイプラインではなく Grunt でやりたくなったんで、Grunt を試してみた。

まず Grunt をインストール。

npm install -g grunt-cli

CoffeeScript と Sass をコンパイルするタスクは Grunt 公式の contrib タスクで提供されているので、 それらもインストール。

npm install grunt-contrib-coffee
npm install grunt-contrib-sass
npm install grunt-contrib-clean

Rails プロジェクトのルートディレクトリに Gruntfile.js を作成。

module.exports = function(grunt) {
  grunt.initConfig({
    coffee: {
      compile: {
        files: {
          "public/assets/application.js": [
            "app/assets/javascripts/home.coffee",
            "app/assets/javascripts/lists.coffee",
            "app/assets/javascripts/tasks.coffee"
          ]
        }
      }
    },
    sass: {
      dist: {
        files: {
          "public/assets/application.css": [
            "app/assets/stylesheets/devise.css.scss",
            "app/assets/stylesheets/home.css.scss",
            "app/assets/stylesheets/lists.css.scss",
            "app/assets/stylesheets/tasks.css.scss",
            "app/assets/stylesheets/scaffolds.css.scss"
          ]
        }
      }
    },
    clean: ["public/assets"]
  });

  grunt.loadNpmTasks("grunt-contrib-coffee");
  grunt.loadNpmTasks("grunt-contrib-sass");
  grunt.loadNpmTasks("grunt-contrib-clean");

  grunt.registerTask("default", ["clean", "coffee", "sass"]);
};

古い出力ファイルを削除してから CoffeeScript と Sass をコンパイルするように、 デフォルトのタスクを設定したので

grunt

を実行すると、public/assets 下に application.js と application.css が出力された。 Rails で使う場合は、出力された application.js と application.css を読み込むように、ビューを修正すればいい。

アセットパイプラインでは CoffeeScript や Sass を編集すると、ブラウザを更新するだけですぐ反映されるけど、 Grunt では watch を使えば同様のことが可能。 Grunt への本格的な移行はまだ先だけど、 Grunt で CoffeeScript と Sass をコンパイルしても問題なさそうだな。