Documentation

You are viewing the documentation for the 2.1.5 release in the 2.1.x series of releases. The latest stable release series is 2.4.x.

§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 の一部としてグループの名前を渡し、そして successerror コールバックを渡して 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 にコミットしてください。

次章 に進みましょう