Add form_with to unify form_tag/form_for. (#26976)

* Add form_with to unify form_tag/form_for.

`form_tag` and `form_for` serve very similar use cases. This
PR unifies that usage such that `form_with` can output just
the opening form tag akin to `form_tag` and can just work with
a url, for instance.

`form_with` by default doesn't attach class or id to the form —
removing them on fields is moved out to a default revisiting PR later.

Ported over old tests where applicable to ensure maximum coverage,
but left some commented out because they don't yet apply (e.g.
`fields_for` later being replaced by `fields`).

[ Kasper Timm Hansen & Marek Kirejczyk ]

* Add fields DSL method.

Strips `_for` and requires models passed as a keyword argument.

* Document form_with.

Graft the `form_for` docs: rewrite, revise and expand where
needed.

Also test that a `format` isn't used when an explicit URL
is passed.

* Enable remote by default.

Brand new world! Forms submit via XHRs by default, woah.

* Invert `include_id` to `skip_id`.

`skip_id: true` reads better than `include_id: false` (since the
`include_id` default is true).

* Invert `remote` to `local`.

Since forms are remote by default, the option name makes more sense
as `local: true`.

* Invert `enforce_utf8` to `skip_enforcing_utf8`.

* Refer to the brand spanking new rails-ujs.

Soon to be bundled in Rails proper, so jquery-ujs is out.

* Make `form_with` a bit more composed.

The flow is still not quite what it should be because the legacy
methods and these new ones pull at opposite ends.

Lots of options have been renamed, so now the new pieces don't fit
in so well.

I'll try to work on this in later commits after this PR (it's likely
there's a much better way to structure this whole part of Action View).
This commit is contained in:
Kasper Timm Hansen 2016-11-21 20:48:51 +01:00 committed by GitHub
parent 5b4ee4db52
commit 67f81cc72d
2 changed files with 2475 additions and 2 deletions

View File

@ -474,6 +474,242 @@ module ActionView
end
private :apply_form_for_options!
# Creates a form tag based on mixing URLs, scopes, or models.
#
# # Using just a URL:
# <%= form_with url: posts_path do |form| %>
# <%= form.text_field :title %>
# <% end %>
# # =>
# <form action="/posts" method="post" data-remote="true">
# <input type="text" name="title">
# </form>
#
# # Adding a scope prefixes the input field names:
# <%= form_with scope: :post, url: posts_path do |form| %>
# <%= form.text_field :title %>
# <% end %>
# # =>
# <form action="/posts" method="post" data-remote="true">
# <input type="text" name="post[title]">
# </form>
#
# # Using a model infers both the URL and scope:
# <%= form_with model: Post.new do |form| %>
# <%= form.text_field :title %>
# <% end %>
# # =>
# <form action="/posts" method="post" data-remote="true">
# <input type="text" name="post[title]">
# </form>
#
# # An existing model makes an update form and fills out field values:
# <%= form_with model: Post.first do |form| %>
# <%= form.text_field :title %>
# <% end %>
# # =>
# <form action="/posts/1" method="post" data-remote="true">
# <input type="hidden" name="_method" value="patch">
# <input type="text" name="post[title]" value="<the title of the post>">
# </form>
#
# The parameters in the forms are accessible in controllers according to
# their name nesting. So inputs named +title+ and <tt>post[title]</tt> are
# accessible as <tt>params[:title]</tt> and <tt>params[:post][:title]</tt>
# respectively.
#
# By default +form_with+ attaches the <tt>data-remote</tt> attribute
# submitting the form via an XMLHTTPRequest in the background if an
# an Unobtrusive JavaScript driver, like rails-ujs, is used. See the
# <tt>:remote</tt> option for more.
#
# For ease of comparison the examples above left out the submit button,
# as well as the auto generated hidden fields that enable UTF-8 support
# and adds an authenticity token needed for cross site request forgery
# protection.
#
# ==== +form_with+ options
#
# * <tt>:url</tt> - The URL the form submits to. Akin to values passed to
# +url_for+ or +link_to+. For example, you may use a named route
# directly. When a <tt>:scope</tt> is passed without a <tt>:url</tt> the
# form just submits to the current URL.
# * <tt>:method</tt> - The method to use when submitting the form, usually
# either "get" or "post". If "patch", "put", "delete", or another verb
# is used, a hidden input named <tt>_method</tt> is added to
# simulate the verb over post.
# * <tt>:format</tt> - The format of the route the form submits to.
# Useful when submitting to another resource type, like <tt>:json</tt>.
# Skipped if a <tt>:url</tt> is passed.
# * <tt>:scope</tt> - The scope to prefix input field names with and
# thereby how the submitted parameters are grouped in controllers.
# * <tt>:model</tt> - A model object to infer the <tt>:url</tt> and
# <tt>:scope</tt> by, plus fill out input field values.
# So if a +title+ attribute is set to "Ahoy!" then a +title+ input
# field's value would be "Ahoy!".
# If the model is a new record a create form is generated, if an
# existing record, however, an update form is generated.
# Pass <tt>:scope</tt> or <tt>:url</tt> to override the defaults.
# E.g. turn <tt>params[:post]</tt> into <tt>params[:article]</tt>.
# * <tt>:authenticity_token</tt> - Authenticity token to use in the form.
# Override with a custom authenticity token or pass <tt>false</tt> to
# skip the authenticity token field altogether.
# Useful when submitting to an external resource like a payment gateway
# that might limit the valid fields.
# Remote forms may omit the embedded authenticity token by setting
# <tt>config.action_view.embed_authenticity_token_in_remote_forms = false</tt>.
# This is helpful when fragment-caching the form. Remote forms
# get the authenticity token from the <tt>meta</tt> tag, so embedding is
# unnecessary unless you support browsers without JavaScript.
# * <tt>:local</tt> - By default form submits are remote and unobstrusive XHRs.
# Disable remote submits with <tt>local: true</tt>.
# * <tt>:skip_enforcing_utf8</tt> - By default a hidden field named +utf8+
# is output to enforce UTF-8 submits. Set to true to skip the field.
# * <tt>:builder</tt> - Override the object used to build the form.
# * <tt>:id</tt> - Optional HTML id attribute.
# * <tt>:class</tt> - Optional HTML class attribute.
# * <tt>:data</tt> - Optional HTML data attributes.
# * <tt>:html</tt> - Other optional HTML attributes for the form tag.
#
# === Examples
#
# When not passing a block, +form_with+ just generates an opening form tag.
#
# <%= form_with(model: @post, url: super_posts_path) %>
# <%= form_with(model: @post, scope: :article) %>
# <%= form_with(model: @post, format: :json) %>
# <%= form_with(model: @post, authenticity_token: false) %> # Disables the token.
#
# For namespaced routes, like +admin_post_url+:
#
# <%= form_with(model: [ :admin, @post ]) do |form| %>
# ...
# <% end %>
#
# If your resource has associations defined, for example, you want to add comments
# to the document given that the routes are set correctly:
#
# <%= form_with(model: [ @document, Comment.new ]) do |form| %>
# ...
# <% end %>
#
# Where <tt>@document = Document.find(params[:id])</tt>.
#
# === Mixing with other form helpers
#
# While +form_with+ uses a FormBuilder object it's possible to mix and
# match the stand-alone FormHelper methods and methods
# from FormTagHelper:
#
# <%= form_with scope: :person do |form| %>
# <%= form.text_field :first_name %>
# <%= form.text_field :last_name %>
#
# <%= text_area :person, :biography %>
# <%= check_box_tag "person[admin]", "1", @person.company.admin? %>
#
# <%= form.submit %>
# <% end %>
#
# Same goes for the methods in FormOptionHelper and DateHelper designed
# to work with an object as a base, like
# FormOptionHelper#collection_select and DateHelper#datetime_select.
#
# === Setting the method
#
# You can force the form to use the full array of HTTP verbs by setting
#
# method: (:get|:post|:patch|:put|:delete)
#
# in the options hash. If the verb is not GET or POST, which are natively
# supported by HTML forms, the form will be set to POST and a hidden input
# called _method will carry the intended verb for the server to interpret.
#
# === Setting HTML options
#
# You can set data attributes directly in a data hash, but HTML options
# besides id and class must be wrapped in an HTML key:
#
# <%= form_with(model: @post, data: { behavior: "autosave" }, html: { name: "go" }) do |form| %>
# ...
# <% end %>
#
# generates
#
# <form action="/posts/123" method="post" data-behavior="autosave" name="go">
# <input name="_method" type="hidden" value="patch" />
# ...
# </form>
#
# === Removing hidden model id's
#
# The +form_with+ method automatically includes the model id as a hidden field in the form.
# This is used to maintain the correlation between the form data and its associated model.
# Some ORM systems do not use IDs on nested models so in this case you want to be able
# to disable the hidden id.
#
# In the following example the Post model has many Comments stored within it in a NoSQL database,
# thus there is no primary key for comments.
#
# <%= form_with(model: @post) do |form| %>
# <%= form.fields(:comments, skip_id: true) do |fields| %>
# ...
# <% end %>
# <% end %>
#
# === Customized form builders
#
# You can also build forms using a customized FormBuilder class. Subclass
# FormBuilder and override or define some more helpers, then use your
# custom builder. For example, let's say you made a helper to
# automatically add labels to form inputs.
#
# <%= form_with model: @person, url: { action: "create" }, builder: LabellingFormBuilder do |form| %>
# <%= form.text_field :first_name %>
# <%= form.text_field :last_name %>
# <%= form.text_area :biography %>
# <%= form.check_box :admin %>
# <%= form.submit %>
# <% end %>
#
# In this case, if you use:
#
# <%= render form %>
#
# The rendered template is <tt>people/_labelling_form</tt> and the local
# variable referencing the form builder is called
# <tt>labelling_form</tt>.
#
# The custom FormBuilder class is automatically merged with the options
# of a nested +fields+ call, unless it's explicitly set.
#
# In many cases you will want to wrap the above in another helper, so you
# could do something like the following:
#
# def labelled_form_with(**options, &block)
# form_with(**options.merge(builder: LabellingFormBuilder), &block)
# end
def form_with(model: nil, scope: nil, url: nil, format: nil, **options)
if model
url ||= polymorphic_path(model, format: format)
model = model.last if model.is_a?(Array)
scope ||= model_name_from_record_or_class(model).param_key
end
if block_given?
builder = instantiate_builder(scope, model, options)
output = capture(builder, &Proc.new)
options[:multipart] ||= builder.multipart?
html_options = html_options_for_form_with(url, model, options)
form_tag_with_body(html_options, output)
else
html_options = html_options_for_form_with(url, model, options)
form_tag_html(html_options)
end
end
# Creates a scope around a specific model object like form_for, but
# doesn't create the form tags themselves. This makes fields_for suitable
# for specifying additional model objects in the same form.
@ -720,6 +956,62 @@ module ActionView
capture(builder, &block)
end
# Scopes input fields with either an explicit scope or model.
# Like +form_with+ does with <tt>:scope</tt> or <tt>:model</tt>,
# except it doesn't output the form tags.
#
# # Using a scope prefixes the input field names:
# <%= fields :comment do |fields| %>
# <%= fields.text_field :body %>
# <% end %>
# # => <input type="text" name="comment[body] id="comment_body">
#
# # Using a model infers the scope and assigns field values:
# <%= fields model: Comment.new(body: "full bodied") do |fields| %<
# <%= fields.text_field :body %>
# <% end %>
# # =>
# <input type="text" name="comment[body] id="comment_body" value="full bodied">
#
# # Using +fields+ with +form_with+:
# <%= form_with model: @post do |form| %>
# <%= form.text_field :title %>
#
# <%= form.fields :comment do |fields| %>
# <%= fields.text_field :body %>
# <% end %>
# <% end %>
#
# Much like +form_with+ a FormBuilder instance associated with the scope
# or model is yielded, so any generated field names are prefixed with
# either the passed scope or the scope inferred from the <tt>:model</tt>.
#
# === Mixing with other form helpers
#
# While +form_with+ uses a FormBuilder object it's possible to mix and
# match the stand-alone FormHelper methods and methods
# from FormTagHelper:
#
# <%= fields model: @comment do |fields| %>
# <%= fields.text_field :body %>
#
# <%= text_area :commenter, :biography %>
# <%= check_box_tag "comment[all_caps]", "1", @comment.commenter.hulk_mode? %>
# <% end %>
#
# Same goes for the methods in FormOptionHelper and DateHelper designed
# to work with an object as a base, like
# FormOptionHelper#collection_select and DateHelper#datetime_select.
def fields(scope = nil, model: nil, **options, &block)
# TODO: Remove when ids and classes are no longer output by default.
if model
scope ||= model_name_from_record_or_class(model).param_key
end
builder = instantiate_builder(scope, model, options)
capture(builder, &block)
end
# Returns a label tag tailored for labelling an input field for a specified attribute (identified by +method+) on an object
# assigned to the template (identified by +object+). The text of label will default to the attribute name unless a translation
# is found in the current I18n locale (through helpers.label.<modelname>.<attribute>) or you specify it explicitly.
@ -1175,6 +1467,32 @@ module ActionView
end
private
def html_options_for_form_with(url_for_options = nil, model = nil, html: {}, local: false,
skip_enforcing_utf8: false, **options)
html_options = options.except(:index, :include_id, :builder).merge(html)
html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted?
html_options[:enforce_utf8] = !skip_enforcing_utf8
html_options[:enctype] = "multipart/form-data" if html_options.delete(:multipart)
# The following URL is unescaped, this is just a hash of options, and it is the
# responsibility of the caller to escape all the values.
html_options[:action] = url_for(url_for_options || {})
html_options[:"accept-charset"] = "UTF-8"
html_options[:"data-remote"] = true unless local
if !local && !embed_authenticity_token_in_remote_forms &&
html_options[:authenticity_token].blank?
# The authenticity token is taken from the meta tag in this case
html_options[:authenticity_token] = false
elsif html_options[:authenticity_token] == true
# Include the default authenticity_token, which is only generated when its set to nil,
# but we needed the true value to override the default of no authenticity_token on data-remote.
html_options[:authenticity_token] = nil
end
html_options.stringify_keys!
end
def instantiate_builder(record_name, record_object, options)
case record_name
@ -1183,7 +1501,7 @@ module ActionView
object_name = record_name
else
object = record_name
object_name = model_name_from_record_or_class(object).param_key
object_name = model_name_from_record_or_class(object).param_key if object
end
builder = options[:builder] || default_form_builder_class
@ -1249,7 +1567,7 @@ module ActionView
# The methods which wrap a form helper call.
class_attribute :field_helpers
self.field_helpers = [:fields_for, :label, :text_field, :password_field,
self.field_helpers = [:fields_for, :fields, :label, :text_field, :password_field,
:hidden_field, :file_field, :text_area, :check_box,
:radio_button, :color_field, :search_field,
:telephone_field, :phone_field, :date_field,
@ -1286,6 +1604,9 @@ module ActionView
@nested_child_index = {}
@object_name, @object, @template, @options = object_name, object, template, options
@default_options = @options ? @options.slice(:index, :namespace) : {}
convert_to_legacy_options(@options)
if @object_name.to_s.match(/\[\]$/)
if (object ||= @template.instance_variable_get("@#{Regexp.last_match.pre_match}")) && object.respond_to?(:to_param)
@auto_index = object.to_param
@ -1293,6 +1614,7 @@ module ActionView
raise ArgumentError, "object[] naming but object param and @object var don't exist or don't respond to to_param: #{object.inspect}"
end
end
@multipart = nil
@index = options[:index] || options[:child_index]
end
@ -1586,6 +1908,13 @@ module ActionView
@template.fields_for(record_name, record_object, fields_options, &block)
end
# See the docs for the <tt>ActionView::FormHelper.fields</tt> helper method.
def fields(scope = nil, model: nil, **options, &block)
convert_to_legacy_options(options)
fields_for(scope || model, model, **options, &block)
end
# Returns a label tag tailored for labelling an input field for a specified attribute (identified by +method+) on an object
# assigned to the template (identified by +object+). The text of label will default to the attribute name unless a translation
# is found in the current I18n locale (through helpers.label.<modelname>.<attribute>) or you specify it explicitly.
@ -1934,6 +2263,16 @@ module ActionView
@nested_child_index[name] ||= -1
@nested_child_index[name] += 1
end
def convert_to_legacy_options(options)
if options.key?(:skip_id)
options[:include_id] = !options.delete(:skip_id)
end
if options.key?(:local)
options[:remote] = !options.delete(:local)
end
end
end
end

File diff suppressed because it is too large Load Diff