Skip to content

Commit

Permalink
Add checkbox support.
Browse files Browse the repository at this point in the history
  • Loading branch information
josevalim committed May 9, 2009
1 parent 65a1666 commit 68f48e6
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 28 deletions.
87 changes: 86 additions & 1 deletion lib/formtastic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ def time_zone_input(method, options)
# You can customize the options available in the set by passing in a collection (Array) of
# ActiveRecord objects through the :collection option. If not provided, the choices are found
# by inferring the parent's class name from the method name and simply calling find(:all) on
# it (VehicleOwner.find(:all) in the example above).
# it (Author.find(:all) in the example above).
#
# Examples:
#
Expand Down Expand Up @@ -733,6 +733,91 @@ def date_or_datetime_input(method, options)
field_set_and_list_wrapping_for_method(method, options, list_items_capture)
end


# Outputs a fieldset containing a legend for the label text, and an ordered list (ol) of list
# items, one for each possible choice in the belongs_to association. Each li contains a
# label and a check_box input.
#
# This is an alternative for has many and has and belongs to many associations.
#
# Example:
#
# f.input :author, :as => :check_boxes
#
# Output:
#
# <fieldset>
# <legend><span>Authors</span></legend>
# <ol>
# <li>
# <input type="hidden" name="book[author_id][1]" value="">
# <label for="book_author_id_1"><input id="book_author_id_1" name="book[author_id][1]" type="checkbox" value="1" /> Justin French</label>
# </li>
# <li>
# <input type="hidden" name="book[author_id][2]" value="">
# <label for="book_author_id_2"><input id="book_author_id_2" name="book[owner_id][2]" type="checkbox" value="2" /> Kate French</label>
# </li>
# </ol>
# </fieldset>
#
# Notice that the value of the checkbox is the same as the id and the hidden
# field has empty value. You can override the hidden field value using the
# unchecked_value option.
#
# You can customize the options available in the set by passing in a collection (Array) of
# ActiveRecord objects through the :collection option. If not provided, the choices are found
# by inferring the parent's class name from the method name and simply calling find(:all) on
# it (Author.find(:all) in the example above).
#
# Examples:
#
# f.input :author, :as => :check_boxes, :collection => @authors
# f.input :author, :as => :check_boxes, :collection => Author.find(:all)
# f.input :author, :as => :check_boxes, :collection => [@justin, @kate]
#
# You can also customize the text label inside each option tag, by naming the correct method
# (:full_name, :display_name, :account_number, etc) to call on each object in the collection
# by passing in the :label_method option. By default the :label_method is whichever element of
# Formtastic::SemanticFormBuilder.collection_label_methods is found first.
#
# Examples:
#
# f.input :author, :as => :check_boxes, :label_method => :full_name
# f.input :author, :as => :check_boxes, :label_method => :display_name
# f.input :author, :as => :check_boxes, :label_method => :to_s
# f.input :author, :as => :check_boxes, :label_method => :label
#
# You can set :value_as_class => true if you want that LI wrappers contains
# a class with the wrapped checkbox input value.
#
def check_boxes_input(method, options)
collection = find_collection_for_column(method, options)
html_options = options.delete(:input_html) || {}

input_name = generate_association_input_name(method)

value_as_class = options.delete(:value_as_class)
unchecked_value = options.delete(:unchecked_value) || ''

list_item_content = collection.map do |c|
label = c.is_a?(Array) ? c.first : c
value = c.is_a?(Array) ? c.last : c

html_options.merge!(:name => "#{@object_name}[#{input_name}][#{value.to_s.downcase}]",
:id => generate_html_id(input_name, value.to_s.downcase))

li_content = template.content_tag(:label,
"#{self.check_box(input_name, html_options, value, unchecked_value)} #{label}",
:for => html_options[:id]
)

li_options = value_as_class ? { :class => value.to_s.downcase } : {}
template.content_tag(:li, li_content, li_options)
end

field_set_and_list_wrapping_for_method(method, options, list_item_content)
end

# Outputs a label containing a checkbox and the label text. The label defaults
# to the column name (method name) and can be altered with the :label option.
# :checked_value and :unchecked_value options are also available.
Expand Down
142 changes: 115 additions & 27 deletions spec/formtastic_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ def custom(arg1, arg2, options = {})
end

it 'should call the corresponding input method' do
[:select, :time_zone, :radio, :date, :datetime, :time, :boolean].each do |input_style|
[:select, :time_zone, :radio, :date, :datetime, :time, :boolean, :check_boxes].each do |input_style|
@new_post.stub!(:generic_column_name)
@new_post.stub!(:column_for_attribute).and_return(mock('column', :type => :string, :limit => 255))
semantic_form_for(@new_post) do |builder|
Expand Down Expand Up @@ -1121,7 +1121,7 @@ def custom(arg1, arg2, options = {})
before do
@new_post.stub!(:author).and_return(@bob)
@new_post.stub!(:author_id).and_return(@bob.id)
@new_post.stub!(:column_for_attribute).and_return(mock('column', :type => :integer, :limit => 255))
Post.stub!(:reflect_on_association).and_return { |column_name| mock('reflection', :klass => Author, :macro => :belongs_to) }
end

describe 'for belongs_to association' do
Expand All @@ -1135,7 +1135,7 @@ def custom(arg1, arg2, options = {})
output_buffer.should have_tag('form li.radio')
end

it 'should have a post_author_id_input id on the wrapper' do
it 'should have a post_author_input id on the wrapper' do
output_buffer.should have_tag('form li#post_author_input')
end

Expand All @@ -1150,18 +1150,16 @@ def custom(arg1, arg2, options = {})
output_buffer.should have_tag('form li fieldset ol li', :count => Author.find(:all).size)
end

it 'should have one option with a "selected" attribute' do
it 'should have one option with a "checked" attribute' do
output_buffer.should have_tag('form li input[@checked]', :count => 1)
end

describe "each choice" do

it 'should contain a label for the radio input with a nested input and label text' do
Author.find(:all).each do |author|
output_buffer.should have_tag('form li fieldset ol li label')
output_buffer.should have_tag('form li fieldset ol li label', /#{author.to_label}/)
output_buffer.should have_tag("form li fieldset ol li label[@for='post_author_id_#{author.id}']")
output_buffer.should have_tag("form li fieldset ol li label input")
end
end

Expand Down Expand Up @@ -1208,14 +1206,11 @@ def custom(arg1, arg2, options = {})
output_buffer.should have_tag("form li fieldset ol li label[@for='project_author_id_#{author.id}']")

output_buffer.should have_tag("form li fieldset ol li label input#project_author_id_#{author.id}")
output_buffer.should have_tag("form li fieldset ol li label input[@type='radio']")
output_buffer.should have_tag("form li fieldset ol li label input[@value='#{author.id}']")
output_buffer.should have_tag("form li fieldset ol li label input[@name='project[author_id]']")
output_buffer.should have_tag("form li fieldset ol li label input[@type='radio'][@value='#{author.id}']")
output_buffer.should have_tag("form li fieldset ol li label input[@type='radio'][@name='project[author_id]']")
end
end

end

end

describe ':as => :select' do
Expand Down Expand Up @@ -1390,6 +1385,93 @@ def custom(arg1, arg2, options = {})
end
end

describe ':as => :check_boxes' do

describe 'for a has_many association' do
before do
semantic_form_for(@fred) do |builder|
concat(builder.input(:posts, :as => :check_boxes, :value_as_class => true))
end
end

it 'should have a check_boxes class on the wrapper' do
output_buffer.should have_tag('form li.check_boxes')
end

it 'should have a author_posts_input id on the wrapper' do
output_buffer.should have_tag('form li#author_posts_input')
end

it 'should generate a fieldset and legend containing label text for the input' do
output_buffer.should have_tag('form li fieldset')
output_buffer.should have_tag('form li fieldset legend')
output_buffer.should have_tag('form li fieldset legend', /Posts/)
end

it 'should generate an ordered list with a list item for each choice' do
output_buffer.should have_tag('form li fieldset ol')
output_buffer.should have_tag('form li fieldset ol li', :count => Post.find(:all).size)
end

it 'should have one option with a "checked" attribute' do
output_buffer.should have_tag('form li input[@checked]', :count => 1)
end

it 'should generate hidden inputs with default value blank' do
output_buffer.should have_tag("form li fieldset ol li label input[@type='hidden'][@value='']", :count => Post.find(:all).size)
end

describe "each choice" do

it 'should contain a label for the radio input with a nested input and label text' do
Post.find(:all).each do |post|
output_buffer.should have_tag('form li fieldset ol li label', /#{post.to_label}/)
output_buffer.should have_tag("form li fieldset ol li label[@for='author_post_ids_#{post.id}']")
end
end

it 'should use values as li.class when value_as_class is true' do
Post.find(:all).each do |post|
output_buffer.should have_tag("form li fieldset ol li.#{post.id} label")
end
end

it 'should have a checkbox input for each post' do
Post.find(:all).each do |post|
output_buffer.should have_tag("form li fieldset ol li label input#author_post_ids_#{post.id}")
output_buffer.should have_tag("form li fieldset ol li label input[@name='author[post_ids][#{post.id}]']", :count => 2)
end
end

it "should mark input as checked if it's the the existing choice" do
Post.find(:all).include?(@fred.posts.first).should be_true
output_buffer.should have_tag("form li fieldset ol li label input[@checked='checked']")
end
end

it 'should generate a fieldset, legend, labels and inputs even if no object is given' do
output_buffer.replace ''

semantic_form_for(:project, :url => 'http://test.host') do |builder|
concat(builder.input(:author_id, :as => :check_boxes, :collection => Author.find(:all)))
end

output_buffer.should have_tag('form li fieldset legend', /Author/)
output_buffer.should have_tag('form li fieldset ol li', :count => Author.find(:all).size)

Author.find(:all).each do |author|
output_buffer.should have_tag('form li fieldset ol li label', /#{author.to_label}/)
output_buffer.should have_tag("form li fieldset ol li label[@for='project_author_id_#{author.id}']")

output_buffer.should have_tag("form li fieldset ol li label input#project_author_id_#{author.id}")
output_buffer.should have_tag("form li fieldset ol li label input[@type='checkbox']")
output_buffer.should have_tag("form li fieldset ol li label input[@value='#{author.id}']")
output_buffer.should have_tag("form li fieldset ol li label input[@name='project[author_id][#{author.id}]']")
end
end
end
end

describe 'for collections' do

before do
Expand All @@ -1398,7 +1480,7 @@ def custom(arg1, arg2, options = {})
@new_post.stub!(:column_for_attribute).and_return(mock('column', :type => :integer, :limit => 255))
end

{ :select => :option, :radio => :input }.each do |type, countable|
{ :select => :option, :radio => :input, :check_boxes => :'input[@type="checkbox"]' }.each do |type, countable|

describe ":as => #{type.inspect}" do
describe 'when the :collection option is not provided' do
Expand Down Expand Up @@ -1453,9 +1535,9 @@ def custom(arg1, arg2, options = {})
concat(builder.input(:category_name, :as => type, :collection => @categories))
end

@categories.each do |item|
output_buffer.should have_tag("form li.#{type}", /#{item}/)
output_buffer.should have_tag("form li.#{type} #{countable}[@value=#{item}]")
@categories.each do |value|
output_buffer.should have_tag("form li.#{type}", /#{value}/)
output_buffer.should have_tag("form li.#{type} #{countable}[@value='#{value}']")
end
end

Expand Down Expand Up @@ -1488,7 +1570,7 @@ def custom(arg1, arg2, options = {})

@categories.each do |label, value|
output_buffer.should have_tag("form li.#{type}", /#{label}/)
output_buffer.should have_tag("form li.#{type} #{countable}[@value=#{value}]")
output_buffer.should have_tag("form li.#{type} #{countable}[@value='#{value}']")
end
end
end
Expand All @@ -1501,12 +1583,13 @@ def custom(arg1, arg2, options = {})

it "should use the first value as the label text and the last value as the value attribute for #{countable}" do
semantic_form_for(@new_post) do |builder|
concat(builder.input(:category_name, :as => :radio, :collection => @categories))
concat(builder.input(:category_name, :as => type, :collection => @categories))
end

@categories.each do |label, value|
output_buffer.should have_tag('form li fieldset ol li label', /#{label}/i)
output_buffer.should have_tag('form li fieldset ol li label input[@value='+value+']')
@categories.each do |text, value|
label = type == :select ? :option : :label
output_buffer.should have_tag("form li.#{type} #{label}", /#{text}/i)
output_buffer.should have_tag("form li.#{type} #{countable}[@value='#{value.to_s}']")
end
end
end
Expand All @@ -1519,12 +1602,13 @@ def custom(arg1, arg2, options = {})

it "should use the symbol as the label text and value for each #{countable}" do
semantic_form_for(@new_post) do |builder|
concat(builder.input(:category_name, :as => :radio, :collection => @categories))
concat(builder.input(:category_name, :as => type, :collection => @categories))
end

@categories.each do |value|
output_buffer.should have_tag('form li fieldset ol li label', /#{value}/i)
output_buffer.should have_tag('form li fieldset ol li label input[@value='+value.to_s+']')
label = type == :select ? :option : :label
output_buffer.should have_tag("form li.#{type} #{label}", /#{value}/i)
output_buffer.should have_tag("form li.#{type} #{countable}[@value='#{value.to_s}']")
end
end
end
Expand Down Expand Up @@ -1581,8 +1665,15 @@ def custom(arg1, arg2, options = {})
end

end
end
end

describe 'for boolean attributes' do

{ :select => :option, :radio => :input }.each do |type, countable|
checked_or_selected = { :select => :selected, :radio => :checked }[type]

describe 'when attribute is a boolean' do
describe ":as => #{type.inspect}" do

before do
@new_post.stub!(:allow_comments)
Expand Down Expand Up @@ -1630,8 +1721,6 @@ def custom(arg1, arg2, options = {})
end
end

checked_or_selected = { :select => :selected, :radio => :checked }[type]

describe 'when the value is nil' do
before do
@new_post.stub!(:allow_comments).and_return(nil)
Expand Down Expand Up @@ -1699,7 +1788,6 @@ def custom(arg1, arg2, options = {})
end
end


end
end
end
Expand Down

8 comments on commit 68f48e6

@jimneath
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hooray!

@jimneath
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I've just had a play around with this. Is there a reason that check_boxes sends the selection as a hash?

"role_ids" => { "1" => "1", "2" => "2" }

Shouldn't this be sent as an array?

@josevalim
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jim, there is no way to send it as an array. :/ Rails encode parameters as hashes (unless I'm missing something!).

@jimneath
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jose, I think if you change the markup from the following:

  • Justin French
  • To something along the lines of this:

  • Justin French
  • Rails will get an array of ids along the lines of:

    author_ids => [1, 2, 3]

    You can then pass that to the author_ids of the model in your controller.

    I'm not entirely sure, but I don't think you need the hidden fields if you do it this way, as if the box is unchecked it simply doesn't get sent. So if you uncheck all the boxes rails will get an empty array for author_ids, which will in turn remove all the associations to authors on that model.

    Hope that made sense?

    @jimneath
    Copy link

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Crap.

    @jimneath
    Copy link

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Should have been:

    <li>
      <input type="hidden" name="book[author_id][1]" value="">
      <label for="book_author_id_1"><input id="book_author_id_1" name="book[author_id][1]" type="checkbox" value="1" /> Justin French</label>
    </li>
    

    to:

    <li>
      <label for="book_author_id_1"><input id="book_author_id_1" name="book[author_id][]" type="checkbox" value="1" /> Justin French</label>
    </li>
    

    @josevalim
    Copy link
    Contributor Author

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Thanks, I will try that.

    @josevalim
    Copy link
    Contributor Author

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Please sign in to comment.