プラグインにまとめてみる。(text_field_with_collection_auto_comolete)

前回実装したtext_field_with_collection_auto_comoleteとcollection_auto_complete_forは、今後よく使いそうなのでプラグインにまとめてみる。以下は、プラグインを作成してみた手順。(自分の環境と使い方の範囲では正常に動作しています。)
ついでに今回は、:scopeオプションを利用して、他のtext_fieldの検索結果と連動して検索できるようにしてみた。これで通常のauto_completeと比べて、以下の機能を追加したことになる。

  • collection_selectのような使い方ができる。
  • :indexオプションが利用できる。(行ごとにauto_completeを設定したい時に便利)
  • :scopeオプションで検索条件を追加できる。(複数のtext_fieldと連動可能)

プラグイン作成の手順

  • RadRailsの「ジェネレーター」タブで、プルダウンリスト「plugin」を選択して、「collection_auto_complete」と入力して実行。
  • vendor/plugins/collection_auto_completeフォルダが作成されて、プラグインの雛形ファイルが追加される。
collection_auto_complete/
  |
  |--lib/ ......実際の処理コードをファイルとして格納する
  |    |--collection_auto_complete.rb
  |
  |--tasks/
  |    |--collection_auto_complete_tasks.rake
  |
  |--test/
  |    |--collection_auto_complete_test.rb
  |
  |--init.rb ......Railsに組み込むためのコード
  |--install.rb
  |--Rakefile
  |--README
  • libフォルダ以下に、実際に処理を行うコードをファイルにまとめて追加する。
    • vendor/plugins/collection_auto_complete/lib/collection_auto_complete.rb(ジェネレーターにより、雛形として自動作成されている。内容は空白。)
    • vendor/plugins/collection_auto_complete/lib/collection_auto_complete_helper.rb(自分で追加。)
    • vendor/plugins/collection_auto_complete/lib/kana.rb(自分で追加。)
  • init.rbには、libフォルダ以下のコードをRailsに組み込む処理を設定する。
    • vendor/plugins/collection_auto_complete/init.rb(ジェネレーターにより、雛形として自動作成されている。内容は空白。)
  • 以上、4ファイルを設定することでプラグインとして利用可能になった。

libフォルダに追加したファイル

# ---------- vendor/plugins/collection_auto_complete/lib/kana.rb ----------

module Kana
  # 全角を半角に変換
  def self.zen2han(str)
    return if str.nil?
    han = str.tr("A-Za-z0-9", "A-Za-z0-9")
    han.tr!('&%()! .,+−ー—', '&%()! .,+-')

    dakuon     = 'ガギグゲゴザジズゼゾダヂヅデドバビブベボヴ'
    daku_clear = 'カキクケコサシスセソタチツテトハヒフヘホウ'
    handakuon     = 'パピプペポ'
    handaku_clear = 'ハヒフヘホ'
    han.gsub!(/[#{dakuon}]/) {|c| c + ''}
    han.gsub!(/[#{handakuon}]/) {|c| c + ''}
    han.tr!(dakuon + handakuon, daku_clear + handaku_clear)

    zenkaku = '。「」、・ァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン゛゜ヵヶヰヱヮ'
    hankaku = '。「」、.ァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン゙゚カケイエワ'
    han.tr(zenkaku, hankaku) || han
  end

  # ひらがなを含めて、全角を半角に変換
  def self.hira2han(str)
    return if str.nil?
    zen2han(str.tr('あ-ん','ア-ン'))
  end

  # 全角を全銀フォーマットで利用可能な半角に変換
  def self.zen2eb(str)
    return if str.nil?
    zen2han(str).upcase.tr('ァィゥェォャュョッ_','アイウエオヤユヨツ-')
  end

  # ひらがなを含めて、全角を全銀フォーマットで利用可能な半角に変換
  def self.hira2eb(str)
    return if str.nil?
    zen2eb(str.tr('あ-ん','ア-ン'))
  end
end
# ---------- vendor/plugins/collection_auto_complete/lib/collection_auto_complete.rb ----------

module CollectionAutoComplete
  # object: 設定したいモデル名。
  # method: 設定したいフィールド名。
  # collection_object: 検索したいモデル名。
  # list_methods: 選択リストに表示したいフィールド名の配列。
  # text_methods: リストを選択した時、text_fieldに表示したいフィールド名の配列。
  # options{:digit=> }: 数字を指定した桁数に揃える。不足した桁は0で埋まる。
  # options{:connect=> }: 入力中のtext_field_with_collection_auto_complete以外で更新したいtext_fieldを指定する。設定書式は以下。
  #                        :connect=>{[更新したいtext_fieldのobject, 更新したいtext_filedのmethod]=>設定したいフィールド名の配列}。
  #                        例: :connect=>{[:branch, :text]=>'', [:payee_account, :branch_id]=>''}
  # options{:scope=> }:   with_scopeの検索条件。設定書式は以下。
  #                        :scope=>{キーとなるフィールド名=>[値を取得したいtext_fieldのobject, 値を取得したいtext_filedのmethod]}
  #                        例: :scope=>{:bank_id=>[:payee_account, :bank_id]とすると、以下のスコープが設定される。
  #                            Branch.with_scope(:find=>:conditions=>["bank_id=?", params[:payee_accont][:bank_id]])
  # 利用例:
  # collection_auto_complete_for :payee_account, :bank_id, :bank, %w(id kana name), %w(id name), 
  #                              :digit=>4, :connect=>{[:branch, :text]=>'', [:payee_account, :branch_id]=>''}
  # collection_auto_complete_for :payee_account, :branch_id, :branch, %w(code kana name), %w(code name), 
  #                              :digit=>3, :scope=>{:bank_id=>[:payee_account, :bank_id]}
  def collection_auto_complete_for(object, method, collection_object, list_methods, text_methods, options = {})
    define_method("collection_auto_complete_for_#{collection_object}_text") do
      @list_methods, @text_methods = list_methods, text_methods
      scope = options.delete(:scope)
      if scope
        sql_text   = scope.keys.map {|key| "#{key}=?"}.join(' AND ')
        sql_values = scope.values.map do |value|
                       params[:index] ? params[value[0]][params[:index]][value[1]] : params[value[0]][value[1]]
                     end
        find_scope = [sql_text] + sql_values
      end
      model_class = collection_object.to_s.camelize.constantize
      model_class.with_scope(:find=>{:conditions=>find_scope}) do
        digit = options.delete(:digit)
        @connect = {}
        options.delete(:connect).each do |keys, value|
          key = params[:index] ? keys.insert(1, params[:index]).join('_') : keys.join('_')
          @connect.merge!(key => value)
        end if options[:connect]
        sarch_text = params[:index] ? params[collection_object.to_s][params[:index]][: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
        find_options = { 
          :conditions => [sql_text] + sql_values, 
          :limit => 10 }.merge!(options)
        @items = model_class.find(:all, find_options)
        index = "_#{params[:index]}" if params[:index]
        render :inline => <<-END
          <%= collection_auto_complete_result(@items, @list_methods, [@phrase_zen, @phrase_han], 
                :digit=>#{digit},
                :connect=>{'#{object}#{index}_#{method}'=>%w(id),
                           '#{collection_object}#{index}_text'=>@text_methods}.merge(@connect) %>
        END
      end
    end
  end
end
  • :scopeオプションを利用して、他のtext_fieldの検索結果と連動して、処理できるようになった。
  • インスタンス変数を使いまくっている。includeするクラスの中で同じ変数名が存在すると、予期せぬ何かが起こるかもしれない...。
# ---------- vendor/plugins/collection_auto_complete/lib/collection_auto_complete_helper.rb ----------

module CollectionAutoCompleteHelper
  # 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

  # 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

  # 数値の桁数を統一した文字列を返す。
  # 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
end

init.rbの内容

# Include hook code here
require_dependency 'kana'
require_dependency 'collection_auto_complete'
require_dependency 'collection_auto_complete_helper'

# モジュールをクラスメソッドとして追加する。
ActionController::Base.extend(CollectionAutoComplete)
# モジュールをヘルパーとして追加する。
ActionController::Base.helper(CollectionAutoCompleteHelper)
  • require_dependencyは、以下のように動作してくれるらしい。
    • development環境の時はload(同じファイルを何度でも読み込む。だから変更が反映される。)
    • production環境の時はrequire(一度読み込んだファイルは再読み込みしない。だからパフォーマンスが落ちない。)
  • ActionController::Base.extend(モジュール名)で、モジュール名の内容をクラスメソッドとして追加できる。(app/controllers/application_controller.rbにクラスメソッドを追加したような状態になる。)
  • ActionController::Base.helper(モジュール名)で、モジュール名の内容をヘルパメソッドとして追加できる。(app/helpers/application.rbにメソッドを追加したような状態になる。)