インデックス付きテーブルフォームの雛形が欲しい!

前回に引き続き、今度は明細行のフォームの雛形も作ってみる。Slipインスタンスのform_forに含まれる明細行はテーブル形式で複数行あり、それぞれが独自のインデックスを持っている。form_for以前の状態は以下のようになっている。

<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<!--[form:journal]-->
<tbody id="<%= @journal.index %>">
<tr valign="top">
  <th>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <%= content_tag 'span', @journal_count, :id=>"journal_#{@journal.index}_number", :class=>'journal_number' %>
    <%= hidden_field 'journal', 'position', :index=>@journal.index, :value=>@journal_count, :class=>'journal_position'  %>
    <%= hidden_field 'journal', 'index', :index=>@journal.index %>
  </th>
  <td align="_center">
    <%= text_field 'journal', 'comment', :index=>@journal.index, :size=>40  %>
    <%= error_messages_on 'journal', 'comment' %>
  </td>
  <td align="_center">
    <%= yen_field 'journal', 'yen', :index=>@journal.index  %>
    <%= error_messages_on 'journal', 'yen' %>
  </td>
</tr>
</tbody>
<!--[eoform:journal]-->

エラーメッセージ付きの雛形フォームを作る

早速、form_forを使って書き直したいところだが...。実は、明細行はSlipインスタンスのform_forブロックに含まれているので、この状況ではform_forは利用できない。*1この場合は、<form> タグを生成しない同等のメソッドfields_forを利用することになる。

  • まずは、シンプルにエラーメッセージ付きの雛形フォームを作ってみる。
# ビルダー: app/helpers/form_with_msg_builder.rb
class FormWithMsgBuilder < ActionView::Helpers::FormBuilder
  selectors = ActionView::Helpers::FormHelper.instance_methods - %w(label form_for field_for hidden_field)
  selectors += ActionView::Helpers::FormOptionsHelper.instance_methods
  selectors.each do |selector|
    define_method(selector) do |field, *args|
      super + 
      @template.error_messages_on(@object_name, field)
    end
  end
end
  • 上記の雛形フォームを利用すると、fields_forを使って以下のように書ける。しかし、この状態ではfield_forを使ったメリットはあまり感じられない...。
<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<% fields_for :journal, :builder=>FormWithMsgBuilder do |j| %>
<!--[form:journal]-->
<tbody id="<%= @journal.index %>">
<tr valign="top">
  <th>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <%= content_tag 'span', @journal_count, :id=>"journal_#{@journal.index}_number", :class=>'journal_number' %>
    <%= j.hidden_field 'position', :index=>@journal.index, :value=>@journal_count, :class=>'journal_position'  %>
    <%= j.hidden_field 'index', :index=>@journal.index %>
  </th>
  <td>
    <%= j.text_field 'comment', :index=>@journal.index, :size=>40  %>
  </td>
  <td>
    <%= j.yen_field 'yen', :index=>@journal.index  %>
  </td>
</tr>
</tbody>
<!--[eoform:journal]-->
<% end %>
  • 改善したいところ
    • :index=>@journal.indexが重複している。省略できるようにしたい。
    • tdタグを付加したい。でも、hidden_fieldだけはtdタグの付加をやめたい。(よく考えたら、error_messages_onの付加も必要ない。)

tdタグの付加、fields_forのオプションをブロック内のフォームにマージ

改善点を考慮して、以下のようにしてみた。

  • 通常は、以下のように書いてもfield_forブロック内のフォームに:indexオプションは設定されない。(:url, :html, :builderオプション以外は無効になるようだ。)
fields_for :journal, :index=>@journal.index do |j|
...(中略)...
end
  • しかし、共通のオプションとして有効になれば、とても便利だ。悩んだ結果、以下のコードで、ブロック内のフォームに共通のオプションを設定することが可能になった。
    • <% fields_for :journal, :index=>@journal.index, :builder=>TdFormWithMsgBuilder do |j| %>が実行される時、FormBuilder親クラスでは以下のように扱われる。
      • 変数optionsまたは@optionsに、ハッシュ{:index=>@journal.index, :builder=>FormWithErrorMessagesBuilder}が代入されている。
    • fields_forブロック内で<%= j.hidden_field :position, :value=>@journal_count, :class=>'journal_position' %>が実行される時、FormBuilder親クラスでは以下のように扱われる。
      • 変数argsに、配列[{:value=>@journal_count, :class=>'journal_position'}]が代入される。
      • argsは配列で、中身の引数の個数が変化する。ハッシュのオプション設定がある場合は、常に配列の最後にハッシュとして登録される。
  • 以上のことを考慮して、下記の11行目以下4行のコードで、field_forブロック内のフォームのオプションとマージしている。
  • また、hidden_fieldは特例扱いになるので、除外して、別途hidden_fieldメソッドを定義した。
# ビルダー: app/helpers/td_form_with_msg_builder.rb
class TdFormWithMsgBuilder < ActionView::Helpers::FormBuilder
  selectors = ActionView::Helpers::FormHelper.instance_methods - %w(label form_for field_for hidden_field)
  selectors += ActionView::Helpers::FormOptionsHelper.instance_methods
  selectors.each do |selector|
    # 事前に設定されている変数の中身...(2行目はオプション設定される時のコード例)
    #   optionsまたは@options = {:index=>@journal.index, :builder=>FormWithErrorMessagesBuilder}
    #     コード例: <% fields_for :journal, :index=>@journal.index, :builder=>TdFormWithMsgBuilder do |j| %>
    #   args = {:value=>@journal_count, :class=>'journal_position'}
    #     コード例: <%= j.hidden_field :position, :value=>@journal_count, :class=>'journal_position'  %>
    define_method(selector) do |field, *args|
      # 11行目: 以下4行でfield_forオプションを、field_forブロック内のフォームのオプションとマージする
      args_hash = args.last.is_a?(Hash) ? args.pop : {}
      form_options = options.dup
      [:url, :html, :builder].each {|key| form_options.delete(key)}
      args << form_options.merge(args_hash)

      @template.content_tag('td', 
        super(field, *args) + 
        @template.error_messages_on(@object_name, field))
    end
  end

  # hidden_fieldだけ特例扱い、以下理由
  #   見えないフィールドにtdタグは設定したくない
  #   form_for等で設定した独自オプション(:index等)だけは有効にしたい
  def hidden_field(field, *args)
    args_hash = args.last.is_a?(Hash) ? args.pop : {}
    form_options = options.dup
    [:url, :html, :builder].each {|key| form_options.delete(key)}
    args << form_options.merge(args_hash)

    super(field, *args)
  end
end


以上の設定をしてTdFormWithMsgBuilderを利用すれば、下記のように書くことができる。

  • 重複していた:index=>@journal.indexも、fields_forのオプションに設定しておくだけでOK。
  • フォームに付属のtdタグが不要になった。
<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<% fields_for :journal, :index=>@journal.index, :builder=>TdFormWithMsgBuilder do |j| %>
<!--[form:journal]-->
<tbody id="<%= @journal.index %>">
<tr valign="top">
  <th>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <%= content_tag 'span', @journal_count, :id=>"journal_#{@journal.index}_number", :class=>'journal_number' %>
    <%= j.hidden_field 'position', :value=>@journal_count, :class=>'journal_position'  %>
    <%= j.hidden_field 'index' %>
  </th>
    <%= j.text_field 'comment', :size=>40  %>
    <%= j.yen_field 'yen'  %>
</tr>
</tbody>
<!--[eoform:journal]-->
<% end %>

tbody、trタグで囲む

さらに、fields_forを拡張するヘルパメソッドtbody_tr_journal_fields_forを定義してみた。

  • fields_forの処理の前後に、concatを使ってtbody、trタグを挿入している。
# ヘルパー: app/helpers/application_helper.rb
module ApplicationHelper
...(中略)...
  def tbody_tr_journal_fields_for(name, *args, &block)
    options = args.last.is_a?(Hash) ? args.pop : {}
    options.merge!(:builder=>TdFormWithMsgBuilder)
    args << options

    concat("<!--[form:journal]-->\n", block.binding)
    concat("<tbody id='#{@journal.index}'>", block.binding)
    concat('<tr valign="top">', block.binding)
      fields_for(name, *args, &block)
    concat('</tr>', block.binding)
    concat('</tbody>', block.binding)
    concat("\n<!--[eoform:journal]-->", block.binding)
  end
end
  • なぜ、concatを使う必要があるのか?(いつものようにcontent_tagを利用できない?)
    • 思い当たることはform_for、field_forは<% Rubyブロック %>だということ。<%= Ruby式 %>の評価結果を表示する場合とは違う。
    • concatの第二引数block.bindingは、form_for、fields_forのブロックが存在する環境を指定しているようだ。
    • つまりconcatは、第一引数の評価結果を、第二引数のブロックが存在する環境に出力するメソッドなのだと理解した。
    • いつものように出力環境を指定せず、単なる文字列を返しただけでは何も表示されないのだ。


これで以下のように書けるようになった。

<%# ビュー: app/views/models/journals/_form.rhtml %>
<% @journal = form || @effect_item %>
<% @journal_count += 1 rescue @journal_count = 1 %>

<% tbody_tr_journal_fields_for :journal, :index=>@journal.index do |j| %>
  <th>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>@journal.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>@journal.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>@journal.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <%= content_tag 'span', @journal_count, :id=>"journal_#{@journal.index}_number", :class=>'journal_number' %>
    <%= j.hidden_field :position, :value=>@journal_count, :class=>'journal_position'  %>
    <%= j.hidden_field :index %>
  </th>
    <%= j.text_field :comment, :size=>40  %>
    <%= j.yen_field :yen  %>
<% end %>
  • シンプルにはなったが、thタグが残っているのにtrタグが見えないのは、何か不安を覚える...。(tbody、trタグは雛形にしない方が良かったかも。)
  • link_to_remoteの:index=>@journal.indexや:submit=>'slip'も重複している。何とかならないかな...。(with_optionsというメソッドもあるが...。)

雛形コードを整理する

思い付くままに試して、作って、を繰り返していると、コードはいつの間にか混沌としてくる。ビューの重複コードを整理していたはずなのに、気付いたら雛形のコードが重複していた...。できる限り整理してみる。

メソッドを分ける
  • 機能を見極めて、再利用できそうな機能をメソッドに分けてみた。
# ビルダー: app/helpers/td_form_with_msg_builder.rb
class TdFormWithMsgBuilder < CustomBaseBuilder
  @@form_helpers = ActionView::Helpers::FormHelper.instance_methods + 
                     ActionView::Helpers::FormOptionsHelper.instance_methods

  (@@form_helpers - %w(label form_for field_for hidden_field)).each do |selector|
    # 事前に設定されている変数の中身...(2行目はオプション設定される時のコード例)
    #   optionsまたは@options = {:index=>@journal.index, :builder=>FormWithErrorMessagesBuilder}
    #     コード例: <% fields_for :journal, :index=>@journal.index, :builder=>FormWithErrorMessagesBuilder do |j| %>
    #   args = {:value=>@journal_count, :class=>'journal_position'}
    #     コード例: <%= j.hidden_field :position, :value=>@journal_count, :class=>'journal_position'  %>
    define_method(selector) do |field, *args|
      @template.content_tag('td', 
        super(field, *merge_options_with(args)) + 
        @template.error_messages_on(@object_name, field))
    end
  end

  # hidden_fieldだけ特例扱い、以下理由
  #   見えないフィールドにtdタグは設定したくない
  #   form_for等で設定した独自オプション(:index等)だけは有効にしたい
  def hidden_field(field, *args)
    super(field, *merge_options_with(args))
  end

private

  # 以下のオプションをマージする
  #   args(f.text_field等のオプション)
  #   @options(form_for,fields_forのオプション)の中の独自オプション
  def merge_options_with(args)
    args_hash = args.last.is_a?(Hash) ? args.pop : {}
    args << form_options.merge(args_hash)
  end

  # フォームに設定する独自オプションだけ取り出す
  def form_options
    _options = @options.dup
    [:url, :html, :builder].each {|key| _options.delete(key)}
    _options
  end
end
雛形基本クラスを作成する(最終的な現状の雛形コード)
# ビルダー: app/helpers/custom_base_builder.rb
class CustomBaseBuilder < ActionView::Helpers::FormBuilder
  class_inheritable_accessor :form_helpers
  self.form_helpers = ActionView::Helpers::FormHelper.instance_methods + 
                        ActionView::Helpers::FormOptionsHelper.instance_methods
  # 上記設定は以下と似ている。参考ページ<http://rubyist.g.hatena.ne.jp/yamaz/20070107>
  #  def self.form_helpers
  #    @@form_helpers = ActionView::Helpers::FormHelper.instance_methods + 
  #                       ActionView::Helpers::FormOptionsHelper.instance_methods
  #  end

private

  # 以下のオプションをマージする
  #   args(f.text_field等のオプション)
  #   @options(form_for,fields_forのオプション)の中の共通オプション
  def merge_options_with(args)
    args_hash = args.last.is_a?(Hash) ? args.pop : {}
    args << form_options.merge(args_hash)
  end

  # フォームに設定する独自オプションだけ取り出す
  def form_options
    _options = @options.dup
    [:url, :html, :builder].each {|key| _options.delete(key)}
    _options
  end

  # オブジェクト名からクラスを取得する
  def object_class
    Object.const_get(@object_name.to_s.classify)
  end
end
  • 上記CustomBaseBuilderクラスを継承して雛形TdFormWithMsgBuilderを作成する
# ビルダー: app/helpers/td_form_with_msg_builder.rb
class TdFormWithMsgBuilder < CustomBaseBuilder
  (form_helpers - %w(label form_for field_for hidden_field)).each do |selector|
    # 事前に設定されている変数の中身...(2行目はオプション設定される時のコード例)
    #   optionsまたは@options = {:index=>@journal.index, :builder=>FormWithErrorMessagesBuilder}
    #     コード例: <% fields_for :journal, :index=>@journal.index, :builder=>FormWithErrorMessagesBuilder do |j| %>
    #   args = {:value=>@journal_count, :class=>'journal_position'}
    #     コード例: <%= j.hidden_field :position, :value=>@journal_count, :class=>'journal_position'  %>
    define_method(selector) do |field, *args|
      @template.content_tag('td', 
        super(field, *merge_options_with(args)) + 
        @template.error_messages_on(@object_name, field))
    end
  end

  # hidden_fieldだけ特例扱い、以下理由
  #   見えないフィールドにtdタグは設定したくない
  #   form_for等で設定した独自オプション(:index等)だけは有効にしたい
  def hidden_field(field, *args)
    super(field, *merge_options_with(args))
  end
end
  • 同様に、ラベル付き雛形のLabelFormWithErrorMessagesBuilderも修正。クラス名を短かめに変更した。(LabelFormWithErrorMessagesBuilder → LabelFormWithMsgBuilder)
# ビルダー: app/helpers/label_form_with_msg_builder.rb
class LabelFormWithMsgBuilder < CustomBaseBuilder
  (form_helpers - %w(label form_for field_for hidden_field)).each do |selector|
    define_method(selector) do |field, *args|
      @template.content_tag('p', 
        @template.content_tag('label', object_class.human_attribute_name(field.to_s), 
                              :for=>"#{@object_name}_#{field}") + '<br/>' + 
        super + 
        @template.error_messages_on(@object_name, field))
    end
  end
end


これで多少はスッキリした!今後は新たに雛形を作成するときも、CustomBaseBuilderクラスを利用して、雛形らしいコードだけを書けば良くなった!

*1:form_forが<form>タグを生成し、<form>タグの中に<form>タグが入ってしまうため。<form>タグは入れ子に出来ないルールになっている。