text_field_with_collection_auto_completeが欲しい!

銀行支店を検索する始めの一歩は、以下のようなコードだった。

<%# ビュー %>
<%= text_field_with_auto_comolete :payee_account, :bank_id %>
# コントローラー
auto_complete_for :payee_account, :bank_id

text_field_with_auto_completeの最もシンプルな形だ。とてもスッキリしている。ところが、使い易さを考えてコードを修正して行くと、前回までのような長い長いコードになってしまった...。やりたいことは実現できたのだが...。
会計システムを作っていると、同じような検索プルダウンリストを他にもたくさん作りたくなる。例えば、科目と補助科目の選択、取引先の選択、従業員の選択...などのケース。ところが、同じような検索プルダウンリストを他にも作ろうとした時、ちょっと気が重くなる...。メソッドをコピーした後の名前の修正は単純なのだが、似たような名前で修正箇所が意外と多く、漏れがあったり、勘違いしていたり...。(あれれ、名前を修正したいんだけど...どんな風に関連していたんだっけ?修正して、実行して、エラー確認しての繰り返しが多くなる。)
そして、気付くのでした。そうだ!text_field_with_collection_auto_completeが欲しい!と。

text_field_with_collection_auto_complete を定義する

text_field_with_auto_completeのソースを参考に、以下のように定義した。

  # ---------- app/helpers/application_helper.rb ----------

  # object: 設定したいモデル名。
  # method: 設定したいフィールド名。
  # collection_object: 検索したいモデル名。
  # tag_options = {}: text_field、hidden_fieldのオプション
  # completion_options = {}: auto_complete_fieldのオプション
  # 例:
  # <%= text_field_with_collection_auto_complete :payee_account, :bank_id, :bank, {:size=>30}, {:min_chars=>-1} %>
  def text_field_with_collection_auto_complete(object, method, collection_object, tag_options = {}, completion_options = {})
    auto_complete_id = tag_options[:index] ? "#{collection_object}_#{tag_options[:index]}_text" : "#{collection_object}_text"
    (completion_options[:skip_style] ? "" : auto_complete_stylesheet) +
    text_field(collection_object, :text, tag_options) +
    content_tag("div", "", :id => "#{auto_complete_id}_auto_complete", :class => "auto_complete") +
    auto_complete_field(auto_complete_id, 
      {:url=>{:action=>"collection_auto_complete_for_#{collection_object}_text", 
              :id=>tag_options[:index]}}.update(completion_options)) +
    hidden_field(object, method, tag_options)
  end
  • id保存用のhidden_fieldを追加した。
  • ついでなので、:indexオプションにも対応できるようにしてみた。

collection_auto_complete_for を定義する

auto_complete_forのソースを参考に、上記ヘルパメソッドとセットのcollection_auto_complete_forを、以下のように定義した。

  # ---------- app/controllers/application.rb ----------

  # object: 設定したいモデル名。
  # method: 設定したいフィールド名。
  # collection_object: 検索したいモデル名。
  # list_methods: 選択リストに表示したいフィールド名の配列。
  # text_methods: リストを選択した時、text_fieldに表示したいフィールド名の配列。
  # options{:digit=> }: 数字を指定した桁数に揃える。不足した桁は0で埋まる。
  # 例:
  # collection_auto_complete_for :payee_account, :bank_id, :bank, %w(id kana name), %w(id name), :digit=>4
  def self.collection_auto_complete_for(object, method, collection_object, list_methods, text_methods, options = {})
    define_method("collection_auto_complete_for_#{collection_object}_text") do
      sarch_text = params[:id] ? params[collection_object.to_s][params[:id]][:text] : params[collection_object.to_s][:text]
      @phrase_zen = sarch_text.split.last.gsub(/[ ]/,' ').strip.downcase rescue ''
      @phrase_han = Kana.hira2eb(@phrase_zen)
      sql_text   = list_methods.map {|list_method| "LOWER(#{list_method}) LIKE ? OR LOWER(#{list_method}) LIKE ?"}.join(' OR ')
      sql_values = list_methods.map {|list_method| ["%#{@phrase_zen}%", "%#{@phrase_han}%"]}.flatten
      digit = options.delete(:digit)
      find_options = { 
        :conditions => [sql_text] + sql_values, 
        :limit => 10 }.merge!(options)
      @items = collection_object.to_s.camelize.constantize.find(:all, find_options)
      render :inline => <<-END
        <%= collection_auto_complete_result(@items, %w(#{list_methods.join(' ')}), [@phrase_zen, @phrase_han], 
              :connect=>{'#{object}_#{method}'=>%w(id), '#{collection_object}_text'=>%w(#{text_methods.join(' ')})}, 
              :digit=>#{digit}) %>
      END
    end
  end
  • 全角と半角、かな、カナの区別無く検索できるようにした。
  • 複数のフィールドを結合した文字列を描画できるようにした。
  • multicontrolls.jsのconnect属性を使って、複数箇所の更新が出来るようにした。

collection_auto_complete_result を定義する

collection_auto_complete_forの中で、選択リストを描画する時に呼び出される。

  # ---------- app/helpers/application_helper.rb ----------

  # entries: 選択リストの配列。
  # fields: 選択リストに表示したいフィールド名の配列。
  # phrase: 強調表示したい文字列の配列。
  # options{:empty_message=> }: 何も見つからない時のメッセージ。(デフォルト値='何も見つかりません。')
  # options{:digit=> }: 数字を指定した桁数に揃える。不足した桁は0で埋まる。
  # options{:connect=>{}}: {接続したいtext_fieldのid属性 => text_fieldに設定したいフィールドの配列}のハッシュで指定する。
  # 例:
  # def auto_complete_for_bank_kana
  #   @phrase_name = params[:bank][:kana].gsub(/[ ]/,' ').strip.downcase
  #   @phrase_kana = Kana.hira2eb(@phrase_name)
  #   find_options = { 
  #     :conditions => ["(LOWER(id) LIKE ? OR LOWER(kana) LIKE ? OR LOWER(name) LIKE ?)",
  #                      "#{@phrase_kana.gsub(/^0+/, '')}", "%#{@phrase_kana}%", "%#{@phrase_name}%"],
  #     :order => "kana ASC",
  #     :limit => 20 }
  #   @items = Bank.find(:all, find_options)
  #   render :inline => <<-END
  #     <%= collection_auto_complete_result @items, %w(id kana name), [@phrase_name, @phrase_kana], 
  #           :digit=>4, :connect=>{:payee_account_bank_id=>%w(id), :bank_kana=>%w(id name)} %>
  #   END
  # end
  def collection_auto_complete_result(entries, fields, phrase = nil, options={})
    options.merge!({:empty_message=>'何も見つかりません。'})
    return content_tag("ul", content_tag("li", options[:empty_message])) if entries.blank?
    items = entries.map do |entry|
      item = []
      fields.each do |field|
        entry_field = fit_digit(entry[field], options[:digit])
        # 全角、半角両方のケースでハイライト処理する。
        highlight_entry_field = (phrase.inject(entry_field){|r, p| highlight(r, p)} rescue highlight(entry_field, phrase))
        item << content_tag("span", phrase ? highlight_entry_field : h(entry_field))
      end
      options[:connect].each do |connect_id, connect_fields|
        connect_value = connect_fields.map {|field| fit_digit(entry[field], options[:digit])}.join(' ')
        item << content_tag("span", h(connect_value), :connect=>connect_id, :style=>"display:none")
      end
      content_tag("li", "#{item.join(' ')}")
    end
    content_tag("ul", items.uniq)
  end

その他の関連ファイル

  # ---------- app/helpers/application_helper.rb ----------

  # 数値の桁数を統一した文字列を返す。
  # fit_digit(1, 4) => "0001"
  # fit_digit(12, 4, '_') => "__12"
  def fit_digit(value, digit, fill_string='0')
    if /^\d+$/ =~ value.to_s
      fill_string * (digit - value.to_s.size) + value.to_s rescue value.to_s
    else
      value.to_s
    end
  end

使い方

以上の定義をすると、銀行検索の部分は以下のように書ける。(支店検索との連動は出来ていない状態)

<%# ビュー %>
<%= text_field_with_collection_auto_complete :payee_account, :bank_id, :bank, {:size=>30}, {:min_chars=>-1} %>
# コントローラー
collection_auto_complete_for :payee_account, :bank_id, :bank, %w(id kana name), %w(id name), {:digit=>4}
      • 支店検索と連動させるためには、コントローラーについてはcollection_auto_complete_forを使わずに、自分で定義する必要がある。

見た目はこんな感じ。