Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Add support for nested forms (http://ryandaigle.com/articles/2009/2/1…

…/what-s-new-in-edge-rails-nested-attributes).

Signed-off-by: Justin French <justin@indent.com.au>
  • Loading branch information...
commit 085e22f7a6aed31f05aca8cfc81d58c39b9e74a4 1 parent ee26f18
@sprsquish sprsquish authored committed
Showing with 165 additions and 71 deletions.
  1. +13 −0 README.textile
  2. +71 −20 lib/formtastic.rb
  3. +81 −51 spec/formtastic_spec.rb
View
13 README.textile
@@ -194,6 +194,19 @@ You don't even have to specify the field list (Formtastic will simply render and
Pretty soon we won't have to write any code at all ;)
+h2. Nested forms (Rails 2.3+)
+
+Nested forms are supported:
+
+<pre>
+ <% semantic_form_for @post do |post| %>
+ <%= post.semantic_fields_for :author do |author| %>
+ <%= author.inputs %>
+ <%= end %>
+ <%= post.buttons %>
+ <% end %>
+</pre>
+
h2. Extending Formtastic
Adding functionality to Formtastic can be done by extending SemanticFormBuilder and configuring formtastic's builder.
View
91 lib/formtastic.rb
@@ -1,3 +1,17 @@
+# Override the default ActiveRecordHelper behaviour of wrapping the input.
+# This gets taken care of semantically by adding an error class to the LI tag
+# containing the input.
+module ActionView
+ module Helpers
+ class InstanceTag
+ alias_method :error_wrapping_with_wrapping, :error_wrapping
+ def error_wrapping(html_tag, has_error)
+ html_tag
+ end
+ end
+ end
+end
+
module Formtastic #:nodoc:
class SemanticFormBuilder < ActionView::Helpers::FormBuilder
@@ -64,7 +78,7 @@ def input(method, options = {})
html_class = [
options[:as].to_s,
(options[:required] ? 'required' : 'optional'),
- (@object.errors.on(method) ? 'error' : nil)
+ (@object.errors.on(method.to_s) ? 'error' : nil)
].compact.join(" ")
html_id = "#{@object_name}_#{method}_input"
@@ -213,8 +227,44 @@ def commit_button(value = save_or_create_commit_button_text, options = {})
template.content_tag(:li, template.submit_tag(value), :class => "commit")
end
+
+ # A thin wrapper around #fields_for to set :builder => Formtastic::SemanticFormBuilder
+ # for nesting forms:
+ #
+ # # Example:
+ # <% semantic_form_for @post do |post| %>
+ # <% post.semantic_fields_for :author do |author| %>
+ # <% author.inputs :name %>
+ # <% end %>
+ # <% end %>
+ #
+ # # Output:
+ # <form ...>
+ # <fieldset class="inputs">
+ # <ol>
+ # <li class="string"><input type='text' name='post[author][name]' id='post_author_name' /></li>
+ # </ol>
+ # </fieldset>
+ # </form>
+ def semantic_fields_for(record_or_name_or_array, *args, &block)
+ opts = args.extract_options!
+ opts.merge!(:builder => Formtastic::SemanticFormBuilder)
+ args.push(opts)
+ fields_for(record_or_name_or_array, *args, &block)
+ end
+
protected
+ # Ensure :object => @object is set before sending the options down to the Rails layer.
+ # Also remove any Formtastic-specific options
+ def set_options(options)
+ opts = options.dup
+ [:value_method, :label_method, :collection, :required, :label, :as, :hint].each do |key|
+ opts.delete(key)
+ end
+ opts.merge(:object => @object)
+ end
+
def save_or_create_commit_button_text #:nodoc:
prefix = @object.new_record? ? "Create" : "Save"
"#{prefix} #{@object_name.to_s.humanize}"
@@ -295,12 +345,12 @@ def select_input(method, options)
options[:collection] ||= find_parent_objects_for_column(method)
options[:label_method] ||= options[:collection].first.respond_to?(:to_label) ? :to_label : :to_s
options[:value_method] ||= :id
-
+
choices = options[:collection].map { |o| [o.send(options[:label_method]), o.send(options[:value_method])] }
- input_label(method, options) + template.select(@object_name, method, choices, options)
+ input_label(method, options) + template.select(@object_name, method, choices, set_options(options))
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 radio input.
@@ -354,7 +404,7 @@ def radio_input(method, options)
options[:collection].map { |c|
template.content_tag(:li,
template.content_tag(:label,
- "#{template.radio_button(@object_name, method, c.id)} #{c.send(options[:label_method])}",
+ "#{template.radio_button(@object_name, method, c.id, set_options(options))} #{c.send(options[:label_method])}",
:for => "#{@object_name}_#{method}_#{c.id}"
)
)
@@ -362,7 +412,7 @@ def radio_input(method, options)
)
)
end
-
+
# Outputs a label and a password input, nothing fancy.
def password_input(method, options)
input_label(method, options) +
@@ -372,7 +422,7 @@ def password_input(method, options)
# Outputs a label and a textarea, nothing fancy.
def text_input(method, options)
- input_label(method, options) + template.text_area(@object_name, method)
+ input_label(method, options) + template.text_area(@object_name, method, set_options(options))
end
@@ -393,7 +443,7 @@ def numeric_input(method, options)
# Outputs label and file field
def file_input(method, options)
input_label(method, options) +
- template.file_field(@object_name, method)
+ template.file_field(@object_name, method, set_options(options))
end
@@ -472,9 +522,10 @@ def date_or_datetime_input(method, options)
break if time_inputs.include?(input)
list_items_capture << template.hidden_field_tag("#{@object_name}[#{method}(#{position[input]}i)]", @object.send(method), :id => "#{@object_name}_#{method}_#{position[input]}i")
else
+ opts = set_options(options).merge({:prefix => @object_name, :field_name => "#{method}(#{position[input]}i)", :include_blank => options[:include_blank]})
list_items_capture << template.content_tag(:li,
template.content_tag(:label, input.to_s.humanize, :for => "#{@object_name}_#{method}_#{position[input]}i") +
- template.send("select_#{input}".intern, @object.send(method), :prefix => @object_name, :field_name => "#{method}(#{position[input]}i)", :include_blank => options[:include_blank])
+ template.send("select_#{input}".intern, @object.send(method), opts)
)
end
end
@@ -489,7 +540,7 @@ def date_or_datetime_input(method, options)
# name (method name) and can be altered with the :label option.
def boolean_input(method, options)
input_label(method, options,
- template.check_box(@object_name, method) +
+ template.check_box(@object_name, method, set_options(options)) +
label_text(method, options)
)
end
@@ -519,7 +570,7 @@ def boolean_select_input(method, options)
options[:false] ||= "No"
choices = [ [options[:true],true], [options[:false],false] ]
- input_label(method, options) + template.select(@object_name, method, choices, options)
+ input_label(method, options) + template.select(@object_name, method, choices, set_options(options))
end
# Outputs a fieldset containing two radio buttons (with labels) for "true" and "false". The
@@ -568,16 +619,16 @@ def boolean_radio_input(method, options)
end
def inline_errors(method, options) #:nodoc:
- errors = @object.errors.on(method).to_a
+ errors = @object.errors.on(method.to_s).to_a
unless errors.empty?
send("error_#{@@inline_errors}", errors) if [:sentence, :list].include?(@@inline_errors)
end
end
-
+
def error_sentence(errors) #:nodoc:
template.content_tag(:p, errors.to_sentence, :class => 'inline-errors')
end
-
+
def error_list(errors) #:nodoc:
list_elements = []
errors.each do |error|
@@ -628,7 +679,7 @@ def field_set_and_list_wrapping(field_set_html_options, contents = '', &block) #
# cases it will simplify (like the case of :integer, :float & :decimal to :numeric), or do
# something different (like :password and :select).
#
- # If there is no column for the method (eg "virtual columns" with an attr_accessor), the
+ # If there is no column for the method (eg "virtual columns" with an attr_accessor), the
# default is a :string, a similar behaviour to Rails' scaffolding.
def default_input_type(object, method) #:nodoc:
# rescue if object does not respond to "column_for_attribute" method
@@ -644,7 +695,7 @@ def default_input_type(object, method) #:nodoc:
return :numeric if [:integer, :float, :decimal].include?(column.type)
return :password if column.type == :string && method.to_s =~ /password/
# otherwise assume the input name will be the same as the column type (eg string_input)
- return column.type
+ return column.type
else
return :password if method.to_s =~ /password/
return :string
@@ -660,18 +711,19 @@ def find_parent_objects_for_column(column)
end
def default_string_options(method) #:nodoc:
- # Use rescue to set column if @object does not have a column_for_attribute method
+ # Use rescue to set column if @object does not have a column_for_attribute method
# (eg if @object is not an ActiveRecord object)
begin
column = @object.column_for_attribute(method)
rescue NoMethodError
column = nil
end
- if column.nil? || column.limit.nil?
+ opts = if column.nil? || column.limit.nil?
{ :size => DEFAULT_TEXT_FIELD_SIZE }
else
{ :maxlength => column.limit, :size => [column.limit, DEFAULT_TEXT_FIELD_SIZE].min }
end
+ set_options(opts)
end
end
@@ -746,5 +798,4 @@ def semantic_#{meth}(record_or_name_or_array, *args, &proc)
module_eval src, __FILE__, __LINE__
end
end
-
end
View
132 spec/formtastic_spec.rb
@@ -246,6 +246,28 @@ def custom(arg1, arg2, options = {})
end
+ describe 'Formtastic::SemanticFormBuilder#semantic_fields_for' do
+ before do
+ @new_post.stub!(:author).and_return(Author.new)
+ end
+
+ it 'yields an instance of SemanticFormBuilder' do
+ semantic_form_for(@new_post) do |builder|
+ builder.semantic_fields_for(:author) do |nested_builder|
+ nested_builder.class.should == Formtastic::SemanticFormBuilder
+ end
+ end
+ end
+
+ it 'nests the object name' do
+ semantic_form_for(@new_post) do |builder|
+ builder.semantic_fields_for(:author) do |nested_builder|
+ nested_builder.object_name.should == 'post[author]'
+ end
+ end
+ end
+ end
+
describe '#input' do
before do
@@ -391,25 +413,25 @@ def custom(arg1, arg2, options = {})
@new_post.stub!(:column_for_attribute).and_return(nil)
default_input_type(nil, :method_without_a_database_column).should == :string
end
-
+
it 'should default to a string for methods on objects that don\'t respond to "column_for_attribute"' do
@new_post.stub!(:method_without_a_database_column)
@new_post.stub!(:column_for_attribute).and_raise(NoMethodError)
default_input_type(nil, :method_without_a_database_column).should == :string
end
-
+
it 'should default to :password for methods that don\'t have a column in the database but "password" is in the method name' do
@new_post.stub!(:password_method_without_a_database_column)
@new_post.stub!(:column_for_attribute).and_return(nil)
default_input_type(nil, :password_method_without_a_database_column).should == :password
end
-
+
it 'should default to :password for methods on objects that don\'t respond to "column_for_attribute" but "password" is in the method name' do
@new_post.stub!(:password_method_without_a_database_column)
@new_post.stub!(:column_for_attribute).and_return(nil)
default_input_type(nil, :password_method_without_a_database_column).should == :password
end
-
+
it 'should default to :select for column names ending in "_id"' do
default_input_type(:integer, :user_id).should == :select
default_input_type(:integer, :section_id).should == :select
@@ -540,26 +562,33 @@ def custom(arg1, arg2, options = {})
before do
@title_errors = ['must not be blank', 'must be longer than 10 characters', 'must be awesome']
@errors = mock('errors')
- @errors.stub!(:on).with(:title).and_return(@title_errors)
+ @errors.stub!(:on).with('title').and_return(@title_errors)
@new_post.stub!(:errors).and_return(@errors)
end
-
+
it 'should apply an errors class to the list item' do
semantic_form_for(@new_post) do |builder|
concat(builder.input(:title))
end
output_buffer.should have_tag('form li.error')
end
-
+
+ it 'should not wrap the input with the Rails default error wrapping' do
+ semantic_form_for(@new_post) do |builder|
+ concat(builder.input(:title))
+ end
+ output_buffer.should_not have_tag('div.fieldWithErrors')
+ end
+
describe 'and the errors will be displayed as a sentence' do
-
+
before do
Formtastic::SemanticFormBuilder.inline_errors = :sentence
- semantic_form_for(@new_post) do |builder|
+ semantic_form_for(@new_post) do |builder|
concat(builder.input(:title))
end
end
-
+
it 'should render a paragraph with the errors joined into a sentence' do
output_buffer.should have_tag('form li.error p.inline-errors', @title_errors.to_sentence)
end
@@ -567,39 +596,39 @@ def custom(arg1, arg2, options = {})
end
describe 'and the errors will be displayed as a list' do
-
+
before do
Formtastic::SemanticFormBuilder.inline_errors = :list
- semantic_form_for(@new_post) do |builder|
+ semantic_form_for(@new_post) do |builder|
concat(builder.input(:title))
end
end
-
+
it 'should render an unordered list with the class errors' do
output_buffer.should have_tag('form li.error ul.errors')
end
-
+
it 'should include a list element for each of the errors within the unordered list' do
@title_errors.each do |error|
output_buffer.should have_tag('form li.error ul.errors li', error)
end
end
-
+
end
-
+
describe 'but the errors will not be shown' do
-
+
before do
Formtastic::SemanticFormBuilder.inline_errors = :none
- semantic_form_for(@new_post) do |builder|
+ semantic_form_for(@new_post) do |builder|
concat(builder.input(:title))
end
end
-
+
it 'should not display an error sentence' do
output_buffer.should_not have_tag('form li.error p.inline-errors')
end
-
+
it 'should not display an error list' do
output_buffer.should_not have_tag('form li.error ul.errors')
end
@@ -618,12 +647,12 @@ def custom(arg1, arg2, options = {})
it 'should not apply an errors class to the list item' do
output_buffer.should_not have_tag('form li.error')
- end
-
+ end
+
it 'should not render a paragraph for the errors' do
output_buffer.should_not have_tag('form li.error p.inline-errors')
end
-
+
it 'should not display an error list' do
output_buffer.should_not have_tag('form li.error ul.errors')
end
@@ -679,12 +708,12 @@ def custom(arg1, arg2, options = {})
it 'should use DEFAULT_TEXT_FIELD_SIZE for methods without database columns' do
should_use_default_size_for_methods_without_columns(:string)
end
-
+
describe "with object that does not respond to 'column_for_attribute'" do
before do
@new_post.stub!(:column_for_attribute).and_raise(NoMethodError)
end
-
+
it "should have a maxlength of DEFAULT_TEXT_FIELD_SIZE" do
should_use_default_size_for_methods_without_columns(:string)
end
@@ -793,23 +822,23 @@ def custom(arg1, arg2, options = {})
end
end
-
+
describe 'when the :label_method option is provided' do
before do
semantic_form_for(@new_post) do |builder|
concat(builder.input(:author_id, :as => :radio, :label_method => :login))
end
end
-
+
it 'should have options with text content from the specified method' do
Author.find(:all).each do |author|
output_buffer.should have_tag("form li fieldset ol li label", /#{author.login}/)
end
- end
+ end
end
-
+
describe 'when the :label_method option is not provided' do
-
+
describe 'when the collection objects repond to :to_label' do
before do
@fred.stub!(:respond_to?).with(:to_label).and_return(true)
@@ -817,14 +846,14 @@ def custom(arg1, arg2, options = {})
concat(builder.input(:author_id, :as => :radio))
end
end
-
+
it 'should render the options with :to_s as the label' do
Author.find(:all).each do |author|
output_buffer.should have_tag("form li fieldset ol li label", /#{Regexp.escape(author.to_label)}/)
end
end
end
-
+
describe 'when the collection objects don\'t respond to :to_label' do
before do
@fred.stub!(:respond_to?).with(:to_label).and_return(false)
@@ -832,14 +861,14 @@ def custom(arg1, arg2, options = {})
concat(builder.input(:author_id, :as => :radio))
end
end
-
+
it 'should render the options with :to_s as the label as a fallback' do
Author.find(:all).each do |author|
output_buffer.should have_tag("form li fieldset ol li label", /#{Regexp.escape(author.to_s)}/)
end
end
end
-
+
end
end
@@ -874,7 +903,7 @@ def custom(arg1, arg2, options = {})
output_buffer.should have_tag("form li select option[@value='#{author.id}']", /#{author.to_label}/)
end
end
-
+
describe 'when the :collection option is not provided' do
it 'should perform a basic find on the parent class' do
@@ -911,13 +940,14 @@ def custom(arg1, arg2, options = {})
describe 'when :include_blank => true, :prompt => "choose something" is set' do
before do
+ @new_post.stub!(:author_id).and_return(nil)
semantic_form_for(@new_post) do |builder|
concat(builder.input(:author_id, :as => :select, :include_blank => true, :prompt => "choose author"))
end
end
it 'should have a blank select option' do
- output_buffer.should have_tag("form li select option[@value='']", / /)
+ output_buffer.should have_tag("form li select option[@value='']", //)
end
it 'should have a select with prompt' do
@@ -936,11 +966,11 @@ def custom(arg1, arg2, options = {})
Author.find(:all).each do |author|
output_buffer.should have_tag("form li select option[@value='#{author.login}']")
end
- end
+ end
end
-
+
describe 'when the :label_method option is not provided' do
-
+
describe 'and the collection objects respond to :to_label' do
before do
output_buffer.replace ''
@@ -949,14 +979,14 @@ def custom(arg1, arg2, options = {})
concat(builder.input(:author_id, :as => :select))
end
end
-
+
it 'should use to_label as the option value' do
Author.find(:all).each do |author|
output_buffer.should have_tag("form li select option", /#{author.to_label}/)
end
end
end
-
+
describe 'and the collection objects do not respond to :to_label' do
before do
output_buffer.replace ''
@@ -965,16 +995,16 @@ def custom(arg1, arg2, options = {})
concat(builder.input(:author_id, :as => :select))
end
end
-
+
it 'should use to_s as the option value as a fallback' do
Author.find(:all).each do |author|
output_buffer.should have_tag("form li select option", /#{Regexp.escape(author.to_s)}/)
end
end
end
-
+
end
-
+
describe 'when the :label_method option is provided' do
before do
semantic_form_for(@new_post) do |builder|
@@ -986,9 +1016,9 @@ def custom(arg1, arg2, options = {})
Author.find(:all).each do |author|
output_buffer.should have_tag("form li select option", /#{author.login}/)
end
- end
+ end
end
-
+
end
end
@@ -1039,17 +1069,17 @@ def custom(arg1, arg2, options = {})
it 'should use DEFAULT_TEXT_FIELD_SIZE for methods without database columns' do
should_use_default_size_for_methods_without_columns(:password)
end
-
+
describe "with object that does not respond to 'column_for_attribute'" do
before do
@new_post.stub!(:column_for_attribute).and_raise(NoMethodError)
end
-
+
it "should have a maxlength of DEFAULT_TEXT_FIELD_SIZE" do
should_use_default_size_for_methods_without_columns(:string)
end
end
-
+
end
describe ':as => :text' do
@@ -1514,12 +1544,12 @@ def custom(arg1, arg2, options = {})
it 'should use DEFAULT_TEXT_FIELD_SIZE for methods without database columns' do
should_use_default_size_for_methods_without_columns(:numeric)
end
-
+
describe "with object that does not respond to 'column_for_attribute'" do
before do
@new_post.stub!(:column_for_attribute).and_raise(NoMethodError)
end
-
+
it "should have a maxlength of DEFAULT_TEXT_FIELD_SIZE" do
should_use_default_size_for_methods_without_columns(:string)
end
Please sign in to comment.
Something went wrong with that request. Please try again.