Play2.0でセレクトボックスにEnumを貼れるようにする

スタプラわー!(挨拶) 第8回全国戦優勝者です(本当)
携帯ゲームって初めてやったけど運営が伏せたルールをプレイヤーが当てる遊び的な感じですね。
折角攻略法見つけても「あれ不具合だから修正しました」て言われる恐ろしい展開もある。


閑話休題、"Play 2.0"もとい"Playframewok 2.0"もとい"Play framework 2.0"で*1、勉強も兼ねて自分用のWebアプリをちょこちょこ作ってるんだけど、いろいろプリミティブな感じで歯がゆい。

例えば、データベースの値をフォームに貼付けるのにいろいろ準備が面倒だと思う。computer-databaseサンプルのComputer編集フォームでセレクトボックスでCompanyを選択する場合で説明すると、次のような感じになる。

  1. ケースクラスComputerにOption[Long]のcompanyIdというフィールドを用意する
  2. Computer編集用のフォームcomputerFormのマッピング"company" -> optional(longNumber)というマッピングを加える
  3. ケースクラスCompanyのコンパニオンオブジェクトにoptionsというメソッドを用意して、DBから取得したCompanyオブジェクトを(IDの文字列、名称)のペアを作ってSeq[(String,String)]として戻すようにする。
  4. テンプレート上で以下のようにヘルパーを呼び出す。
            @select(
                computerForm("company"), 
                Company.options, 
                '_label -> "Company", '_default -> "-- Choose a company --",
                '_showConstraints -> false
            )

どの辺がいけてないなーと思うかというと、UI部品であるselectが要求するSeq[(String,String)]をモデルオブジェクト側で用意しているところと、そうやって用意しておいても、結局フォーム受信時にはLongとして受け取っていて、選択されたCompanyの内容を利用しようとするとDBから取得し直さないところ。

このselectを一つの再利用可能な部品としてみた時に、普通に考えれば「Companyのリストを渡したら、ユーザが選択したCompanyが取得出来る」というのが一番便利だと思うんだけど、そんな風に作れないものなのか。

ということで練習としてセレクトボックスにEnumを貼って、選択されたEnumを受け取れるようにしてみた。

Enum(列挙型)およびFormatterの定義

衣装データを形状と色とで表現したい。形状は文字列で入力するとして、色はあらかじめ決められた8色から選択するようにする。

package models

import Color._

case class Costume(shape:String, color:Color)
package models

object Color extends Enumeration {
    type Color = Value
    val Black = Value("黒")
    val White = Value("白")
    val Brown = Value("茶")
    val Green = Value("緑")
    val Yellow = Value("黄")
    val Red = Value("赤")
    val Purple = Value("紫")
    val Blue = Value("青")
}

これを、フォームの値として持てるようにしたい。Formのmappingで扱えるようにするには、bindとunbindを定義したFormatterをその型用に用意する。

package data.format

import scala.util.control.Exception.allCatch

import models.Color
import models.Color._
import play.api.data.format.Formats.intFormat
import play.api.data.format.Formatter
import play.api.data.FormError

object ColorFormat {
    implicit def colorFormat: Formatter[Color] = new Formatter[Color] {
    
        override val format = Some("format.color", Nil)
    
        def bind(key: String, data: Map[String, String]): Either[Seq[FormError], Color] = {
            for ( n <- intFormat.bind(key, data).right;
                 c <- allCatch.either(Color(n)).left.map(_ => Seq(FormError(key, "error.range", Nil))).right)
                yield c
        }

        def unbind(key: String, value: Color) = Map(key -> value.id.toString)
    }
}

フォーム上での値としてはidを文字列化したものを使う。bindでは文字列→Colorの変換を、unbindではColor→文字列の変換を行う。

このFormatterを使って、例えば文字列とColorの二つのフィールドを持つフォームは次のように書ける。

import models.Costume
import models.Color._

import play.api.data.Forms._
import play.api.data.Form
import data.format.ColorFormat.colorFormat  // colorFormatをimportしておくと

...
    val sampleForm = Form(
        mapping(
            "shape" -> text,
            "color" -> of[Color]            // implicitなFormatter[Color]として利用される
        )(Costume.apply)(Costume.unapply)
    )

これで、サーバ側ではColorとして扱い、HTML側ではidの文字列として扱えるようになった。

フォームヘルパーの定義

基本的にはselect.scala.htmlを参考に作りたいけど、HTMLテンプレート上で型パラメータが取れないようなのでScalaで書き下す。

package views.html.helper

import play.api.data.format.Formatter
import play.api.data.Field
import play.api.templates.Html
import play.api.templates.HtmlFormat._
import play.api.templates.PlayMagic._

object dataselect {
    def apply[A](field: Field, options: Seq[A], args: (Symbol,Any)*)(f: A => Html)
            (implicit handler: FieldConstructor, lang: play.api.i18n.Lang, binder: Formatter[A]): Html = {
        // 標準のフィールドコンストラクタで装飾
        input(field, args:_*) { (id, name, value, htmlArgs) =>
            // 開始タグ
            Html("""<select id="%s" name="%s" %s>""".format(escape(id), escape(name), toHtmlArgs(htmlArgs)) +
            // 空値用の選択肢があれば表示
            args.toMap.get('_default).map { defaultValue =>
                """<option class="blank" value="">%s</option>""".format(escape(defaultValue.toString()))
            }.getOrElse("")) + 
            // options分optionタグを繰り返す。中身のレンダリングは引数で受け取った関数でおこなう
            options.map { (a:A) =>
                binder.unbind("_tmpkey_", a).get("_tmpkey_").map {v => 
                    Html("""<option value="%s" %s>""".format(v, if(value == Some(v)) "selected" else "")) +
                    f(a) +
                    Html("""</option>""")
                }.getOrElse(Html(""))
            }.reduce(_ + _) +
            // 閉じタグ
            Html("""</select>""")
        }
    }
}

implicitでスコープ上で定義されたFormatterを取得し、選択肢に対応する値を取り出している。*2 *3 また、optionの中身は関数を渡してレンダリングするようにしている。

これを使って、セレクトボックスを含むフォームを書ける。

@(sampleForm:Form[Costume])
@import helper._
@import views.html.helper.dataselect
@import data.format.ColorFormat.colorFormat

    @form(routes.Sample.update()) {
        @inputText(sampleForm("shape"), '_label -> "衣装の形状")
        @dataselect(sampleForm("color"), Color.values.toSeq, '_label -> "衣装の色") { color =>
          @color色
        }
        <input type="submit" value="送信" />
    }

これで、「黒色」〜「青色」のセレクトボックスが表示され、選択した値がColorとしてフォームから取り出せるようになる。

備考

基本的な問題意識としては

と同じですわ。進歩が無い……

*1:検索しにくいんだよksg

*2:本当はFormのmappingsから取得出来れば良いんだけど、当該フィールドのmappingを型とともに取り出す方法が分からなかった。

*3:変換失敗時は例外を投げるべきな気がするが、何例外が適当か分からないのでスキップしている