§Javascript によるアクションの実行
このチュートリアルのひとつ前の章で、ナビゲーションドローワーの舞台裏となるいくつかの新しいアクションを実装しました。この章では、このナビゲーションドローワーの振る舞いを完成するために必要なクライアント側のコードを追加します。
§Javasctipt ルート
最初に実装する必要があるのは Javascript ルーターです。常にただハードコードした URL を使って AJAX 呼び出しを行うこともできますが、Play はこれらの URL を組み立てて AJAX リクエストを送信するクライアントサイドルーターを提供しています。AJAX 呼び出しを行う URL の組み立てはとても壊れ易くなりがちで、URL の構造や引数の名前をちょっとでも変更していると、それは Javascript コードを更新する際に簡単に忘れられてしまいます。このような理由から、Play にはサーバー上のアクションを、まるで直接実行するかのように呼び出すことのできる Javascript ルーターが備わっています。
Javascript ルーターは、どのアクションが含まれるべきかを示すためにコードから生成される必要があります。これは、クライアント側のコードが script タグを使ってダウンロードすることのできる通常のアクションとして実装することができます。別の方法として、Play はテンプレートへのルーターの埋め込みもサポートしていますが、ここでは単純にアクションメソッドを使います。以下のように、app/controllers/Application.java
に Javascript ルーターを書いてください:
public static Result javascriptRoutes() {
response().setContentType("text/javascript");
return ok(
Routes.javascriptRouter("jsRoutes",
controllers.routes.javascript.Projects.add(),
controllers.routes.javascript.Projects.delete(),
controllers.routes.javascript.Projects.rename(),
controllers.routes.javascript.Projects.addGroup()
)
);
}
このルーターは Javascript ファイルになるので、レスポンスのコンテントタイプを text/javascript
に設定しました。その後、ルートを生成するために Routes.javascriptRouter
を使いました。第一引数に jsRoutes
を渡しているのは、ルーターがこの名前のグローバル変数に結びつくことを意味しており、そのため Javascript/CoffeeScript コードでは、この名前を使ってルーターにアクセスすることができます。続いて、ルーターで使いたいアクションのリストを渡しました。
もちろん、conf/routes
ファイルにルートを追加する必要があります:
GET /assets/javascripts/routes controllers.Application.javascriptRoutes()
クライアント側のコードを実装する前に、app/views/main.scala.html
で必要になるすべての javascript の依存性を調達する必要があります:
<script type="text/javascript" src="@routes.Assets.at("javascripts/jquery-1.7.1.js")"></script>
<script type="text/javascript" src="@routes.Assets.at("javascripts/jquery-play-1.7.1.js")"></script>
<script type="text/javascript" src="@routes.Assets.at("javascripts/underscore-min.js")"></script>
<script type="text/javascript" src="@routes.Assets.at("javascripts/backbone-min.js")"></script>
<script type="text/javascript" src="@routes.Assets.at("javascripts/main.js")"></script>
<script type="text/javascript" src="@routes.Application.javascriptRoutes()"></script>
§CoffeeScript
CoffeeScript を使ってクライアント側のコードを実装しましょう。CoffeeScript は、Javascript を便利にするものです。CoffeeScript は Javascript にコンパイルされ、Javascript と完全に互換性があるので、CoffeeScript のサンプルとして Javascript ルーターを使うことができます。Javascript を使うこともできますが、Play には CoffessScript コンパイラが組み込まれているので、Javascript の代わりに CoffessScript を使ってみましょう。main.scala.html
に Javascript の依存性を追加した際、main.js
への依存性を追加しました。これは CoffeeScript をコンパイルすると生成されることになる成果物なので、まだ存在しません。さっそく実装しましょう。app/assets/javascripts/main.coffee
を開いてください:
$(".options dt, .users dt").live "click", (e) ->
e.preventDefault()
if $(e.target).parent().hasClass("opened")
$(e.target).parent().removeClass("opened")
else
$(e.target).parent().addClass("opened")
$(document).one "click", ->
$(e.target).parent().removeClass("opened")
false
$.fn.editInPlace = (method, options...) ->
this.each ->
methods =
# public methods
init: (options) ->
valid = (e) =>
newValue = @input.val()
options.onChange.call(options.context, newValue)
cancel = (e) =>
@el.show()
@input.hide()
@el = $(this).dblclick(methods.edit)
@input = $("<input type='text' />")
.insertBefore(@el)
.keyup (e) ->
switch(e.keyCode)
# Enter key
when 13 then $(this).blur()
# Escape key
when 27 then cancel(e)
.blur(valid)
.hide()
edit: ->
@input
.val(@el.text())
.show()
.focus()
.select()
@el.hide()
close: (newName) ->
@el.text(newName).show()
@input.hide()
# jQuery approach: http://docs.jquery.com/Plugins/Authoring
if ( methods[method] )
return methods[ method ].apply(this, options)
else if (typeof method == 'object')
return methods.init.call(this, method)
else
$.error("Method " + method + " does not exist.")
上記のコードに少し圧倒されたかもしれません。コードの最初の段落は、単純な jquery であり、ページ内のすべての option のアイコンを活性化します。二番目の段落は、ある領域をその場で編集できるように変更する jquery の拡張で、これは後ほど利用します。これらは、ロジックの残りの部分を書く際に必要となるユーティリティメソッドに過ぎません。
Backbone ビューを書いていきましょう:
class Drawer extends Backbone.View
$ ->
drawer = new Drawer el: $("#projects")
Drawer
と呼ばれる Backbone ビューに、projects
の id を持つ drawer を結び付けました。しかし、今のところ何も便利になっていません。drawer の初期化メソッドで、すべてのグループを新しい Group
クラスに、そしてそれぞれのグループ内のすべてのプロジェクトを新しい Project
クラスに結び付けましょう:
class Drawer extends Backbone.View
initialize: ->
@el.children("li").each (i,group) ->
new Group
el: $(group)
$("li",group).each (i,project) ->
new Project
el: $(project)
class Group extends Backbone.View
class Project extends Backbone.View
それではグループにいくつかの振る舞いを追加します。最初に、グループを隠したり表示したりできるよう、切り替え機能を追加しましょう:
class Group extends Backbone.View
events:
"click .toggle" : "toggle"
toggle: (e) ->
e.preventDefault()
@el.toggleClass("closed")
false
以前にグループのテンプレートを作成した際、新しいプロジェクト用のボタンを含む、いくつかのボタンを追加しました。これにクリックイベントを結び付けましょう:
class Group extends Backbone.View
events:
"click .toggle" : "toggle"
"click .newProject" : "newProject"
newProject: (e) ->
@el.removeClass("closed")
jsRoutes.controllers.Projects.add().ajax
context: this
data:
group: @el.attr("data-group")
success: (tpl) ->
_list = $("ul",@el)
_view = new Project
el: $(tpl).appendTo(_list)
_view.el.find(".name").editInPlace("edit")
error: (err) ->
$.error("Error: " + err)
以前に作った Javascript ルーターである jsRoutes
を使っていることが分かります。これは、ほとんど Projects.add
アクションを通常どおり呼び出しているように見えます。これを起動すると、ajax リクエストを行うメソッドのほか、アクションの URL およびメソッドを取得する機能を提供するオブジェクトが返却されます。しかし、ここでは data
の一部としてグループの名前を渡し、そして success
と error
コールバックを渡して ajax
を実行しているのが分かります。実際のところ、ajax
メソッドは URL とメソッドを jQuery の ajax
メソッドの作法に従ってそのまま委譲しているだけなので、jQuery でできることはすべてここで行うことができます。
Javascript ルーターを jQuery と併せて使うのは、Play が提供するデフォルト実装に過ぎず、jQuery を使わなければならないということではありません。Javascript ルーターを生成する際に、呼び出す独自の関数名を引き渡すことで、どんな ajax 関数でも使うことができます。
ここでページをリフレッシュすると、新しいプロジェクトを作成できるようになっているはずです。しかし、新しいプロジェクトの名前は “New Project” であり、望ましいものではありません。これをリネームする機能を実装しましょう:
class Project extends Backbone.View
initialize: ->
@id = @el.attr("data-project")
@name = $(".name", @el).editInPlace
context: this
onChange: @renameProject
renameProject: (name) ->
@loading(true)
jsRoutes.controllers.Projects.rename(@id).ajax
context: this
data:
name: name
success: (data) ->
@loading(false)
@name.editInPlace("close", data)
error: (err) ->
@loading(false)
$.error("Error: " + err)
loading: (display) ->
if (display)
@el.children(".delete").hide()
@el.children(".loader").show()
else
@el.children(".delete").show()
@el.children(".loader").hide()
最初に、以前に追加したヘルパー関数を使い、かつコールバックとして renameProject
メソッドを渡すことで、その場で編集可能なプロジェクト名を定義しました。この renameProject
メソッドで、今度はリネームしようとするプロジェクトの id を引数として渡しながら再度 Javascript ルーターを使っています。ここで、プロジェクト上でダブルクリックして、プロジェクトをリネームできるかどうかを確認してみてください。
プロジェクト用に最後に実装したいのは、削除ボタンに結び付いた削除メソッドです。Project
Backbone クラスに以下の CoffessScript を追加してください:
events:
"click .delete" : "deleteProject"
deleteProject: (e) ->
e.preventDefault()
@loading(true)
jsRoutes.controllers.Projects.delete(@id).ajax
context: this
success: ->
@el.remove()
@loading(false)
error: (err) ->
@loading(false)
$.error("Error: " + err)
false
ここで再び、Projects
コントローラ上の delete メソッドを実行するために Javascript ルーターを使っています。
最後のタスクとして行いたいのは、メインテンプレートに新規グループボタンを追加し、このロジックを実装することです。app/views/main.scala.html
テンプレートの </nav>
タグの直前に新規グループボタンを追加しましょう:
</ul>
<button id="newGroup">New group</button>
</nav>
それでは、Drawer
クラスに addGroup
メソッドを追加し、このメソッドを newGroup
ボタンのクリックに結び付けるコードを initialize
に追加します:
class Drawer extends Backbone.View
initialize: ->
...
$("#newGroup").click @addGroup
addGroup: ->
jsRoutes.controllers.Projects.addGroup().ajax
success: (data) ->
_view = new Group
el: $(data).appendTo("#projects")
_view.el.find(".groupName").editInPlace("edit")
これで新規グループが作成できるようになったことを試してみてください。
§クライアントサイドコードのテスト
万事うまく動作することを手動でテストしてきましたが、Javascript アプリケーションはとてももろくなりがちで、そして将来的には簡単に壊れてしまいます。Play は、FluentLenium を使ってクライアントサイドコードをテストするとてもシンプルな機構を提供しています。FluentLenium は、ページとコンポーネントを再利用できるやり方でシンプルに表現する方法を提供し、ページと相互にやり取りして、その妥当性を検証させてくれます。
§ページオブジェクトの実装
ログインページを表現するページを作成するところから始めましょう。test/pages/Login.java
を開いてください:
package pages;
import org.fluentlenium.core.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
import components.*;
import controllers.*;
public class Login extends FluentPage {
public String getUrl() {
return routes.Application.login().url();
}
public void isAt() {
assertThat(find("h1", withText("Sign in"))).hasSize(1);
}
public void login(String email, String password) {
fill("input").with(email, password);
click("button");
}
}
メソッドが三つあるのが分かります。まず、利便性のためにリバースルーターを使ってページの URL を取得して、これを宣言しました。その後、このページにいることを確認するために、このページ上でいくつかのアサーションを実行する isAt
メソッドを実装しました。FluentLenium は、すべてが期待どおりであることを確認するためにこのページを訪れたとき、このメソッドを使用します。ここでは、見出しがログインページのものであることを確認するシンプルなアサーションを書きました。最後に、ログインフォームにユーザの email とパスワードを入力し、その後でログインボタンをクリックする、このページのアクションを実装しました。
FluentLenium の詳細と提供されている API は ここ で読むことができます。このチュートリアルでは、これ以上詳しく触れません。
これでログインできるようになりましたので、test/pages/Dashboard.java
にダッシュボードを表現するページを作りましょう:
package pages;
import org.fluentlenium.core.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
import components.*;
public class Dashboard extends FluentPage {
public String getUrl() {
return "/";
}
public void isAt() {
assertThat(find("h1", withText("Dashboard"))).hasSize(1);
}
public Drawer drawer() {
return Drawer.from(this);
}
}
ログインページと同様にシンプルです。最終的にはこのページにより多くの機能を追加しますが、今は drawer をテストしたいだけなので、drawer を取得するメソッドだけを提供します。どのようにして test/components/Drawer.java
に drawer を実装するのか見ていきましょう:
ackage components;
import java.util.*;
import org.fluentlenium.core.*;
import org.fluentlenium.core.domain.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
public class Drawer {
private final FluentWebElement element;
public Drawer(FluentWebElement element) {
this.element = element;
}
public static Drawer from(Fluent fluent) {
return new Drawer(fluent.findFirst("nav"));
}
public DrawerGroup group(String name) {
return new DrawerGroup(element.findFirst("#projects > li[data-group=" + name + "]"));
}
}
drawer はページではなく、ページのコンポーネントであるため、今回は FluentPage
を拡張せず、drawer が存在する <nav>
要素をである FluentWebElement
をシンプルにラップしました。グループを名前で検索するメソッドを提供したので、ここで test/components/DrawerGroup.java
を開いて、グループをどのように実装するのか見てみましょう:
package components;
import org.fluentlenium.core.*;
import org.fluentlenium.core.domain.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
import java.util.*;
import com.google.common.base.Predicate;
public class DrawerGroup {
private final FluentWebElement element;
public DrawerGroup(FluentWebElement element) {
this.element = element;
}
public List<DrawerProject> projects() {
List<DrawerProject> projects = new ArrayList<DrawerProject>();
for (FluentWebElement e: (Iterable<FluentWebElement>) element.find("ul > li")) {
projects.add(new DrawerProject(e));
}
return projects;
}
public DrawerProject project(String name) {
for (DrawerProject p: projects()) {
if (p.name().equals(name)) {
return p;
}
}
return null;
}
public Predicate hasProject(final String name) {
return new Predicate() {
public boolean apply(Object o) {
return project(name) != null;
}
};
}
public void newProject() {
element.findFirst(".newProject").click();
}
}
Drawer
のように、プロジェクトを名前で検索するメソッドがあります。また、特定の名前のプロジェクトが存在するか確認するメソッドも提供しました。後ほど、特定の条件が true になるまで待つように FluentLenium に告げる際、簡単にこれを捕捉する Predicate
を使っています。
最後に作成するモデルのコンポーネントは test/componenst/DrawerProject.java
です:
package components;
import org.fluentlenium.core.*;
import org.fluentlenium.core.domain.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
import org.openqa.selenium.Keys;
import com.google.common.base.Predicate;
public class DrawerProject {
private final FluentWebElement element;
public DrawerProject(FluentWebElement element) {
this.element = element;
}
public String name() {
FluentWebElement a = element.findFirst("a.name");
if (a.isDisplayed()) {
return a.getText().trim();
} else {
return element.findFirst("input").getValue().trim();
}
}
public void rename(String newName) {
element.findFirst(".name").doubleClick();
element.findFirst("input").text(newName);
element.click();
}
public Predicate isInEdit() {
return new Predicate() {
public boolean apply(Object o) {
return element.findFirst("input") != null && element.findFirst("input").isDisplayed();
}
};
}
DrawerProject
は、プロジェクトの名前を探したり、プロジェクトをリネームすることができますし、プロジェクト名が編集中であることを確認するプレディケートも用意されています。さて、ここまで作業するためにずいぶんと selenium テストから遠ざかってしまいましたし、まだテストを何も書いていません! すばらしいことに、これらのコンポーネントとページはすべての selenium テストから再利用することができるので、マークアップを何か変更しても、これらのコンポーネントを更新するだけで、すべてのテストが動き続けます。
§テストの実装
test/views/DrawerTest.java
を開いて、以下のセットアップコードを追加してください:
package views;
import org.junit.*;
import play.test.*;
import static play.test.Helpers.*;
import org.fluentlenium.core.*;
import static org.fest.assertions.Assertions.assertThat;
import static org.fest.assertions.fluentlenium.FluentLeniumAssertions.assertThat;
import static org.fluentlenium.core.filter.FilterConstructor.*;
import com.google.common.base.*;
import pages.*;
import components.*;
public class DrawerTest extends WithBrowser {
public Drawer drawer;
@Before
public void setUp() {
start();
Login login = browser.createPage(Login.class);
login.go();
login.login("[email protected]", "secret");
drawer = browser.createPage(Dashboard.class).drawer();
}
}
今回は、テストケースと共に動作する web ブラウザのモックを提供する WithBrowser
を継承してテストケースを作成しました。デフォルトのブラウザは Java ベースのヘッドレスブラウザである HtmlUnit ですが、Firefox や Chrome など、その他のブラウザを使うこともできます。setUp
メソッドで、ブラウザとサーバの両方を起動する start()
を呼び出しました。その後、ログインページを作成し、ログインページに遷移して、ログインし、そして最後にダッシュボードページを作成して、ここから drawer を検索しました。これで最初のテストケースを書く準備が整いました:
@Test
public void newProject() throws Exception {
drawer.group("Personal").newProject();
dashboard.await().until(drawer.group("Personal").hasProject("New project"));
dashboard.await().until(drawer.group("Personal").project("New project").isInEdit());
}
ここではプロジェクトの新規作成をテストしています。drawer に定義済みのメソッドを使って Personal
グループを取得します。グループがプロジェクトを持っていること、またプロジェクトが編集中であることを (プロジェクトを作成した後に) テストするために書いたプレディケートを使っているのが分かります。別のテストも書いてみましょう。今度はリネーム機能をテストします:
@Test
public void renameProject() throws Exception {
drawer.group("Personal").project("Private").rename("Confidential");
dashboard.await().until(Predicates.not(drawer.group("Personal").hasProject("Private")));
dashboard.await().until(drawer.group("Personal").hasProject("Confidential"));
dashboard.await().until(Predicates.not(drawer.group("Personal").project("Confidential").isInEdit()));
}
プロジェクトをリネームして、これが消えるのを待ち、新しいプロジェクト名が表示されるのを待ち、そして新しいプロジェクト名が編集中でないことを確認しています。このチュートリアルでは、ここでクライアントサイドのテストから手を引きますが、あなたはここで、プロジェクトを削除し、新しいグループを作成するテストを実装してみることができます。
ここまでで、ナビゲーションドローワーは動作し、テストされました。いくつかのアクションをどのように実装するか、Javascript ルーターがどのように動作するか、どのようにして CoffeeScript を Play アプリケーションで使用するか、そしてどのようにして CoffeeScript コードから Javascript ルーターを使用するかについて見てきました。また、ヘッドレスブラウザで実行されるクライアントサイドコードをテストするために、ページオブジェクトパターンを使う方法も見てきました。グループのリネームと、グループの削除を含むいくつかの機能はまだ実装していません。あなた自身でこれらを実装してみるか、どのように実装するのか ZenTasks サンプルアプリを確認することができます。
作業内容を git にコミットしてください。
次章 に進みましょう