render :partialカウンター、オブジェクト

Railsでページを表示する時に必ずお世話になるrenderメソッド。そんな一番身近とも言えるrenderメソッドなのに、昨日初めて知った機能がある。小さな機能なのだが、今まで知らずに使っていて大分損をした気分だ...。

覚えたこと

以下のようなコード例で考えて...

  • render :partialに:collectionオプションが設定されていると...
    • パーシャルファイル(_list_item.rhtml)のファイル名*1のローカル変数(list_item)に@slipsの内容を代入して、描画処理を実行する。(知っていた。)
    • さらにローカル変数list_item_counter*2には0から始まるインデックス値(0, 1, 2, 3...)*3が代入されている!(知らなかった...。)
      • Rails 2.1.0では1から始まるインデックス値(1, 2, 3, 4...)に変更になった。
  • ローカル変数list_itemに@slipを設定して描画処理する。(:collectionオプション無しの時でも)
    • <%= render :partial=>'list_item', :locals=>{:list_item=>@slip} %>(知っていた。)
    • <%= render :partial=>'list_item', :object=>@slip %>(知らなかった...。)
# コントローラー: slips_controller.rb
class SlipsController < ApplicationController
  def list
    @slips = [1000,2000,3000].map {|price| Slip.new(:yen=>price)}
  end
end
<%# ビュー: list.rhtml %>
描画結果...
<%= render :partial=>'list_item', :collection=>@slips %>

<%# 上記renderは以下の処理に近い %>
<%# @slips.each do |slip| %>
  <%#= render :partial=>'list_item', :object=>slip %>
<%# end %>
<%# ビュー: _list_item.rhtml %>
<%= list_item_counter %> |
<%= list_item.yen %>
描画結果...
0 | 1000
1 | 2000
2 | 3000

Rails 2.1.0の場合
1 | 1000
2 | 2000
3 | 3000
      • 普通にみんな知っていることだろうか?

実際に利用

修正前

前回までの伝票の明細行を表示する部分は以下のようになっていた。上記renderオプションとform_forを覚えた今、1,2行目は不要になるはず。

<%# ビュー: app/views/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 %>
修正後
  • form_forのオプションとしてオブジェクトをローカル変数formで指定する。(tbody_tr_journal_fields_formメソッドの第2引数form)
  • @journalを、ローカル変数formに置き換えた。
  • @journal_countを、ローカル変数form_counter + 1に置き換えた。
<%# ビュー: app/views/journals/_form.rhtml %>
<% tbody_tr_journal_fields_for :journal, form, :index=>form.index do |j| %>
  <th>
    <%= link_to_remote "+", {:submit=>'slip', :url=>{:action=>'insert_row', :index=>form.index}}, {:title=>"1行挿入"} %>
    <%= link_to_remote "ー", {:submit=>'slip', :url=>{:action=>'delete_row', :index=>form.index}}, {:title=>"1行削除"} %>
    <%= link_to_remote "▼", {:submit=>'slip', :url=>{:action=>'copy_row', :index=>form.index}}, {:title=>"1行コピー"} %>
  </th>
  <th align="right">
    <%= content_tag 'span', form_counter + 1, :id=>"journal_#{form.index}_number", :class=>'journal_number' %>
    <%= j.hidden_field :position, :value=>form_counter + 1, :class=>'journal_position'  %>
    <%= j.hidden_field :index %>
  </th>
  <%= j.text_field :comment, :size=>40  %>
  <%= j.yen_field :yen  %>
<% end %>

目障りだった1,2行目がなくなって、とてもスッキリした感じに。ただし、上記の修正だけではうまく動かない...。今まで@journalに依存して書いていたコードを以下のように改める必要があった。

  • tbody_tr_journal_fields_forヘルパーで、@journalに依存していた部分も修正する。(オレンジ色の部分をoptions[:index]に変更。以前は@journal.index。)
  • :objectオプション対応のyen_fieldにする。(オレンジ色の部分options[:object] || を追記。)
# ヘルパー: 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='#{options[: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

module ActionView
  module Helpers
    module FormHelper
      def yen_field(object_name, method, options = {})
        # object_nameに基づくオブジェクト(モデルのインスタンス)から、methodが示すフィールドの値を取得している。
        # 例: yen_field 'slip', 'total_yen' --> @slip.total_yenがvalueに設定される。
        object = options[:object] || self.instance_variable_get("@#{object_name}")
        value = object.send(method)
        # デフォルトのオプション設定
        options.merge!(:value=>number_with_delimiter(value), 
                       :autocomplete=>'off', 
                       :style=>"text-align:right")
        InstanceTag.new(object_name, method, self, nil, options.delete(:object)).to_input_field_tag("text", options)
      end
    end
...(中略)...
  • 挿入、コピーする時にも同じ_form.rhtmlを使って明細行を描画しているので、ローカル変数formを利用するように修正した。(オレンジ色の部分を追記)
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
...(中略)...
  def render_insert(position)
    render :update do |page|
      page.insert_html position, params[:index], render(:partial=>'journals/form', :object=>@effect_item)
      highlight_row(page, @effect_item.index, :duration=>2)
      numbering_row(page, @effect_item.position - 1)
      page.replace 'journals_footer', :partial=>'journals/footer'
    end
  end
...(中略)...
  • TdFormWithMsgBuilderクラスの中で、error_messages_onの呼び出しも@journalに依存していた。(オレンジ色の部分をobjectに修正。以前は@object_name。)
# ビルダー: 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, field))
    end
  end

  # hidden_fieldだけ特例扱い、以下理由
  #   見えないフィールドにtdタグは設定したくない
  #   form_for等で設定した独自オプション(:index等)だけは有効にしたい
  def hidden_field(field, *args)
    super(field, *merge_options_with(args))
  end
end
  • ちなみに、自分で追加定義したerror_messages_onはオブジェクト名、またはオブジェクトそのものを第一引数にした呼び出しに対応できているが、Rails1.2.6までの既存メソッドerror_message_onはオブジェクトそのものを設定した呼び出しに対応できていない。(rails2.0.2は対応している。)


修正箇所が分散して、エラーの究明に手こずってしまったが、最初から@journnalに頼らないコードなら、何も問題ないはず。今後は少し得した気分で、ますますシンプルに書けそうだ!

*1:_アンダースコアと拡張子を除外したファイル名

*2:_アンダースコアと拡張子を除外したファイル名に_counterを追記した変数名になる。

*3:パーシャルファイルの描画1回ごとに1ずつ増えていく。つまり0から始まる行番号を表示するようなもの。