rails で FormObject を使う

フォームでは日付の期間を入力し、それを日単位のレコードに保存するようなケースでは、FormObject を使えるかも。やってみた。


こういうフォーム。

Model

uniqueness など Model 単位でバリデーションしなければいけないものや、コンテキストに関係なくバリデーションするものは Model に書く。

app/models/foo_day.rb

class FooDay < ActiveRecord::Base
  belongs_to :user

  validates :user_id, presence: true
  validates :date, presence: true, uniqueness: {scope: :user_id}
end

FormObject

FormObject の参考では、よく virtus を include しているサンプルがあるけど、Virtus の Type cast が効果的に使えそうなケース以外だと特に使わなくて良いかなと個人的に思った。
例えば今回のケースだと、view から文字列で渡ってくる YYY-MM-DD を Date に変換したかったので、一度 Virtus を使ってみたけど、 例えば 2016-08-32 のように Date として解釈できないものが渡ってきた場合に、エラーにならずに文字列のまま変数に格納される挙動だったので、あまり積極的に使う理由が無かった。
結局、日付の validation のために validates_timeliness という gem を使ったけど便利だった。

app/models/foo_day/registration_form.rb

class FooDay::RegistrationForm
  include ActiveModel::Model

  attr_accessor :user_id, :from_date, :to_date

  validates :user_id, presence: true
  validates :from_date, presence: true, timeliness: {on_or_after: :today, type: :date}
  validates :to_date, allow_blank: true, timeliness: {on_or_after: :from_date, type: :date}

  def save
    return false unless valid?

    persist!
    true
  end

  private

  def foo_days
    @foo_days ||= build_foo_days
  end

  def build_foo_days
    if to_date.blank?
      [FooDay.new(date: from_date, user_id: user_id)]
    else
      (Date.parse(from_date)..Date.parse(to_date)).map {|date| FooDay.new(user_id: user_id, date: date) }
    end
  end

  def valid?
    return false unless super

    foo_days.each do |foo_day|
      next if foo_day.valid?

      foo_day.errors.full_messages.each do |message|
        errors.add(:base, I18n.t('activemodel.errors.invalid_foo_day', date: foo_day.date, message: message))
      end
    end

    return super
  end

  def persist!
    foo_days.each(&:save!)
  end
end

Controller

app/controllers/foo_days_controller.rb

class FooDaysController < ApplicationController
  def new
    @registration_form = FooDay::RegistrationForm.new
  end

  def create
    @registration_form = FooDay::RegistrationForm.new(params[:foo_day_registration_form].merge(user_id: current_user.id))

    if @registration_form.save
      redirect_to new_foo_day_path, notice: '登録に成功しました'
    else
      flash.now[:alert] = '登録に失敗しました'

      render action: :new
    end
  end
end

View

app/views/foo_days/new.html.haml

.row
  %h1 日々の登録
  = form_for @registration_form, url: foo_days_path, method: :post do |f|
    - if @registration_form.errors.any?
      %ul
        - @registration_form.errors.full_messages.each do |msg|
          %li= msg

    = f.label '日付'
    = f.date_field :from_date
    %span 〜
    = f.date_field :to_date

    = f.submit '確定'

Translation

config/locales/ja.yml

ja:
  activemodel:
    attributes:
      foo_day/registration_form:
        from_date: 開始日
        to_date: 終了日
    errors:
      invalid_foo_day: '%{date} %{message}'
      models:
        foo_day/registration_form:
          attributes:
            from_date:
              on_or_after: は %{restriction} 以降の日付を指定してください
            to_date:
              on_or_after: は 開始日 以降の日付を指定してください

まとめ

フォームとモデルが1対1で対応しないケースもたまにあるので、その時は FormObject も選択肢の一つになるかも。

参考