インデックス付きテーブルフォームの雛形が欲しい!
前回に引き続き、今度は明細行のフォームの雛形も作ってみる。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は配列で、中身の引数の個数が変化する。ハッシュのオプション設定がある場合は、常に配列の最後にハッシュとして登録される。
- <% fields_for :journal, :index=>@journal.index, :builder=>TdFormWithMsgBuilder do |j| %>が実行される時、FormBuilder親クラスでは以下のように扱われる。
- 以上のことを考慮して、下記の11行目以下4行のコードで、field_forブロック内のフォームのオプションとマージしている。
- また、hidden_fieldは特例扱いになるので、除外して、別途hidden_fieldメソッドを定義した。
-
-
- 配列引数の挙動については、配列引数 - rubyco(るびこ)さんの日記が大変参考になりました。感謝です!
-
# ビルダー: 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を利用できない?)
これで以下のように書けるようになった。
<%# ビュー: 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
雛形基本クラスを作成する(最終的な現状の雛形コード)
- 前回作成した雛形LabelFormWithErrorMessagesBuilderとも重複が発生している。共通メソッドを保持するCustomBaseBuilderクラスを作ってみた。
-
- class_inheritable_accessorについてはRailsにおけるアクセサ定義系メソッドのまとめ - yamazさんのRails日記 - Rubyistが大変参考になりました。感謝です!
-
# ビルダー: 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クラスを利用して、雛形らしいコードだけを書けば良くなった!