Play2.0で日付入力部品を作る

"Play 2.0"もとい"Playframewok 2.0"もとい"Play framework 2.0"で*1、既存のコンポーネントを組み合わせて、新しいコンポーネントを作りたい。
例えば、日付を入力するのに、セレクトボックスで「年」「月」「日」を選んで入力するフォームを考える。
こういうのは結構頻繁に再利用するので、再利用可能な部品として作っておきたい。

日付用のMapperの定義

「名前」と「誕生日」をフィールドとして持つ「人物」クラスを考える。

import java.util.Date

case class Person(name: String, birthday: Date)

これを入力するためのフォームを定義する。例えばこんな感じ

   val personForm = Form(
      mapping(
         "name" -> nonEmptyText,
         "birthday" -> date
      )(Person.apply)(Person.unapply)
   )

dateはplay.api.data.Forms._で定義されているMapping[java.util.Date]のインスタンス

  val date: Mapping[java.util.Date] = of[java.util.Date]

これはFormatter[java.util.Date]をimplicitに要求していて、その実体はplay.api.data.format.Formatで定義されている。内容を見ると、"yyyy-MM-dd"の書式で文字列とDateの変換をしていた。つまり、入力フォームに「yyyy-MM-dd」で入力してもらうような用途を想定しているようだ。

そうではなく、3フィールドに「年」「月」「日」を入力したら、日付として受け取れるようにしたい。
フォームのmappingは入れ子に出来るようなので、こういう風に書ける

   val personForm = Form(
      mapping(
         "name" -> nonEmptyText,
         "birthday" -> mapping(
               "year" -> number,
               "month" -> number,
               "day" -> number
         )(applyDate)(unapplyDate)
      )(Person.apply)(Person.unapply))

applyDateとunapplyDateは年,月,日の数値の組とDateを変換する関数で、下のような感じで用意しておく。

import org.joda.time._
import java.util.Date

   // Int,Int,Int からDateを生成する
   def applyDate(year: Int, month: Int, day: Int) =
      new DateMidnight(year, month, day) toDate

   // DateからOption[(Int,Int,Int)]を生成する
   def unapplyDate(data: Date) = {
      val datetime = new DateTime(data)
      Some(datetime.getYear, datetime.getMonthOfYear, datetime.getDayOfMonth)
   }

これで、personForm("birthday.year")、personForm("birthday.month")、personForm("birthday.day")のようにFieldを引き出してHTML上でフォーム部品を組み立てられる。

mappingを毎回書くのは面倒なので、切り出して定義しておく

package data.format

import play.api.data._
import play.api.data.Forms._
import org.joda.time._
import java.util.Date

// ※注意このMappingは例外が起こるんで安全ではないよ
object DateComponentMapping {
   // Int,Int,Int からDateを生成する
   def applyDate(year: Int, month: Int, day: Int) =
      new DateMidnight(year, month, day) toDate 

   // DateTimeからOption[(Int,Int,Int)]を生成する
   def unapplyDate(data: Date) = {
      val datetime = new DateTime(data)
      Some(datetime.getYear, datetime.getMonthOfYear, datetime.getDayOfMonth)
   }

   // 年、月、日で構成される日付フォーム
   def dateComponent: Mapping[Date] = 
      mapping(
            "year" -> number,
            "month" -> number,
            "day" -> number
      )(applyDate)(unapplyDate)
}

使う側:

import data.format.DateComponentMapping.dateComponent

   val personForm = Form(
      mapping(
         "name" -> nonEmptyText,
         "birthday" -> dateComponent
      )(Person.apply)(Person.unapply)
   )

これでAction側では細部のフィールド構成を特に意識せず入力をDateオブジェクトで受け取れるようになった。

日付入力部品のHTMLの定義

日付用のMappingが準備できたので、それに対応する形で再利用可能なHTML部品を作りたい。
具体的には、上記のdateComponentが設定されたフィールドを受け取って、その内部の「年」「月」「日」の3つのフィールドをプルダウン3個に結びつけるようなHTMLテンプレートを書く。

app/views/helper/dateselector.scala.html:

@(field: play.api.data.Field, args: (Symbol,Any)*)(implicit handler: FieldConstructor, lang: play.api.i18n.Lang)
@defining(
   (1900 to 2020).map(_ toString).map(s => (s,s)),
   (1 to 12).map(_ toString).map(s => (s,s)),
   (1 to 31).map(_ toString).map(s => (s,s))
) { case(years, months, days) =>
   @select(field("year"), years, args:_*)
   @select(field("month"), months, args:_*)
   @select(field("day"), days, args:_*)
}

ちょっと手抜きで年は1900〜2020の固定にしてるけど、ここはパラメータで指定できても良いかも。
個々のフィールドは、親のフィールドに対してfield("year")のようにして取り出すことができる。

呼び出す側はform("birthday")などのように親のフィールドだけ渡してやればよい。

@(person: Form[Person], message: String)
@import helper._

...
    @form(routes.Sample.update) {
        @inputText(person("name"))
        @dateselector(person("birthday"))
        <input type="submit" value="登録">
    }

まあエラー表示をちゃんとしたりselectを横並びにしたかったりした場合はHTML側でもう少し整理が必要と思うけど、原理的にはこれで可能なことが分かる。
こうやって作った部品をさらに組み合わせて、例えば日付の範囲を入力するための部品を作ったりもできるはず。

入力チェックについて

ドキュメントを見ると、ad-hocな制約を掛ける例がtuple用のものしかなく、mappingで定義した場合にどうやるか分からなかった。
一つの方法としては、tupleで作ったあとtransform()で目的の型に変換するように書けばいけるかも。
mappingのままやる場合、何が難しいかと言うと、子のフィールドでintに変換した後、元のmappingのapplyが呼ばれる間にverifyが設定できないのが困る。
仕方ないので、applyで例外が投げられたら拾ってFormErrorで返すようにしてみた。

   // 恒等関数
   def id[A](a: A) = a

   // mapping()で作ったMappingでapply時に例外が発生するようなものはad-hoc constraintsが掛けられないので、ラッパーを作成
   def safeMapping[A](wrapped: Mapping[A], e: (String, Throwable) => Seq[FormError]): WrappedMapping[A, A] =
      new WrappedMapping(wrapped, id[A], id[A]) {
         override def bind(data: Map[String, String]): Either[Seq[FormError], A] =
            allCatch.either(super.bind(data)).left.map(e(this.key, _)).joinRight
         override def withPrefix(prefix: String): Mapping[A] =
            safeMapping(wrapped.withPrefix(prefix), e)
      }

   // 年、月、日で構成される日付フォーム
   def dateComponent: Mapping[Date] = safeMapping(
      mapping("year" -> number, "month" -> number, "day" -> number)(applyDate)(unapplyDate),
      { case (key, e) => Seq(FormError(key, "error.date", Nil)) }
   )

WrappedMappingの実装に依存した感じであんまりちゃんとした実装じゃないけど。

感想

Play2.0では、フォームを処理するのにMappingをうまく部品化すれば、それらを組み合わせることで完結&柔軟に複雑なフォームを組み立てていけることが分かった。

また、テンプレートエンジン側でも再利用可能な部品を組み合わせて複雑なフォームを組み立てていけることが分かった。

でも、ひとつの複雑なフォームを作るのに、Form側とHTMLテンプレート側で部品をペアで用意して、かつ、使用するときには別々にツリーを作っていかなくてはいけない。これはDRYじゃないと思う。

例えば、JavaServer Faces(というかTapestry(とういかWebObjects))のように、HTMLテンプレート側でコンポーネントツリーを一回作ったら、リクエスト時とレスポンス時の両方でそれを使えるようになってればいいと思うんだよね。

言い換えれば、一つ一つの部品がビュー的な部分(表示)とコントローラ的な部分(入力)をセットで持っていて、そういうプラガブルな部品を組み立ててビュー全体とコントローラ全体を作れるようになっていると便利だと思う。

なんでそうなってないんだろう。なんで15年前(WebObjects)は出来ていたことが今できてないんだろう。

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