Documentation

You are viewing the documentation for the 2.2.x release series. The latest stable release series is 2.4.x.

§Javascript によるアクションの実行

このチュートリアルのひとつ前の章で、バックエンドで提供されるナビゲーションドローワー向けにいくつかの新しいアクションを実装しました。この章では、このナビゲーションドローワーの振る舞いを完成するために必要なクライアント側のコードを追加します。

§Javasctipt ルート

最初に実装する必要があるのは Javascript ルーターです。常にただハードコードした URL を使って AJAX 呼び出しを行うこともできますが、Play は AJAX リクエスト向けにこれらの URL を供給するクライアントサイドルーターを提供しています。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 を強化するもので、Javascript に変換され、Javascript と完全な互換性を持ちます。例えば、CoffeeScript のサンプルとして Javascript ルーターを使うことができます。Javascript を使うこともできますが、Play には CoffessScript コンパイラが組み込まれているので、Javascript の代わりに CoffessScript を使ってみましょう!

main.scala.html に Javascript の依存性を追加した際、main.js への依存性を追加しました。これは CoffeeScript を変換すると生成されることになる成果物なので、まだ存在しません。さっそく 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")
        r = jsRoutes.controllers.Projects.add()
        $.ajax
            url: r.url
            type: r.type
            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 と型 (メソッド) を提供するオブジェクトが返却されます。

Javascript ルーターを jQuery と併せて使う必要はありません。ルーターは url と型 (メソッド) をシンプルに解決します。

ここでページをリフレッシュすると、新しいプロジェクトを作成できるようになっているはずです。しかし、新しいプロジェクトの名前は “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)
        r = jsRoutes.controllers.Projects.rename(@id)
        $.ajax
            url: r.url
            type: r.type
            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)
        r = jsRoutes.controllers.Projects.delete(@id)
        $.ajax
            url: r.url
            type: r.type
            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: ->
        r = jsRoutes.controllers.Projects.addGroup()
        $.ajax
            url: r.url
            type: r.type
            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 のように、プロジェクトを名前で検索するメソッドがあります。特定の名前のプロジェクトが存在するか確認するメソッドも提供し、Predicate を使ってこれを補足しました。Predicate を使うと、あとで特定の条件が true になるまで待つように FluentLenium に告げるのが簡単になります。

最後に作成するモデルのコンポーネントは 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 にコミットしてください。

次章 に進みましょう


このドキュメントの翻訳は Play チームによってメンテナンスされているものではありません。 間違いを見つけた場合、このページのソースコードを ここ で確認することができます。 ドキュメントガイドライン を読んで、お気軽にプルリクエストを送ってください。