Archive

Archive for June 9th, 2009

Rails auto_complete nested list

June 9th, 2009 1 comment

Yesterday I was implementing auto completion for categories. The idea was to get possibility to write by hand categories but keep them as has_many_through association in database.

After six hours of coding I got this working – that is why I’m against rails, with such big amount of plugins, gems and blogs you have to spent a lot of time to do small things.

This code works with rails 2.3

So first we have to install auto_complete plugin which was quite handy:

script/plugin install auto_complete
script/plugin discover

Second we need to define migration:

  create_table :book_categories do |t|
    t.references :books
    t.references :categories
  end
  create_table :books do |t|
    t.string :title
  end
  create_table :categories do |t|
    t.string :name
  end

At third step we define models:

class Book < ActiveRecord::Base
  has_many :book_categories, :dependent => :destroy
  has_many :categories, :through => :book_categories
end
class BookCategory < ActiveRecord::Base
  belongs_to :book
  belongs_to :category
end
class Category < ActiveRecord::Base
  has_many :book_categories, :dependent => :destroy
  has_many :books, :through => :book_categories
end

Time for forth step – faking Book properties:

class Book < ActiveRecord::Base
has_many :book_categories, :dependent => :destroy
has_many :categories, :through => :book_categories
def categories_list
self.categories.map{|c| c.name}*”, ”
end

def categories_list=(list)
list_names = list.split(‘,’).map{ |e| e.strip }.uniq
list_existings = Category.find(:all, :conditions => [ ‘name IN (?)’, list_names ], :select => ‘id,name’ )
list_existings_names = list_existings.map { |e| e.name }
list_add = list_names-list_existings_names
list_new = list_existings.map { |e| e.id }
list_old = self.categories.map { |e| e.id }.uniq
list_add.each do |name|
self.categories << Category.new(:name => name)
end
self.categories << Category.find(:all, :conditions => [ ‘id IN (?)’, list_new-list_old ], :select => ‘id’ )
self.categories.delete Category.find(:all, :conditions => [ ‘id IN (?)’, list_old-list_new ], :select => ‘id’ )
end
end

Now we can have some rest from coding and play a bit with new models using “script/console”:

>> Category.new(:name => 'a').save
>> Category.new(:name => 'b').save
>> Category.new(:name => 'c').save
>> Category.all.map{|c| [c.id,c.name] }
=> [[1, "a"], [2. "b"], [3, "c"]]
>> b = Book.new(:title=>'some book')
>> b.categories_list='a,b,c'
>> b.categories
=> [#<Category id: 1>, #<Category id: 2>, #<Category id: 3>]
>> b.categories_list='a,c,d'
>> b.categories
=> [#<Category id: 1>, #<Category id: 3>, #<Category id: nil, name: "d">]
>> b.save
>> Category.all.map{|c| [c.id,c.name] }
=> [[1, "a"], [2. "b"], [3, "c"], [4, "d"]]

Fifth step is to write edit/new view:

<% form_for(@book) do |f| %>
  <div>
    <%= f.label :title, t(:book_label_title) %><br />
    <%= f.text_field :title %>
  </div>
  <div>
    <%= f.label :categories_list, t(:book_label_category) %><br />
    <%= text_field_with_auto_complete :book, :categories_list, {}, { :method => :get } %>
  </div>
<% end %>

Six step is to define action in controller:

class BooksController < ApplicationController
  def auto_complete_for_book_categories_list
    list = params['book']['categories_list'].split(',').map{ |e| e.strip }

    name = list.pop

    find_options = {
      :conditions => [ "name LIKE ?", '%' + name + '%' ],
      :order => "name ASC",
      :limit => 10,
      :select => 'id, name',
    }

    @items = Category.find(:all, find_options).select { |e| !list.include?(e.name) }.
      map { |e| list.push(e.name); e.name=(list*', '); list.pop(); e }

    render :inline => "<%= auto_complete_result @items, 'name' %>"
  end
  #other actions go here ...
end

The seventh and last step is to add routing:

map.resources :books, :collection => { :auto_complete_for_book_categories_list => :get }

Hope that this will help someone.

Categories: Development Tags: , ,