-
Notifications
You must be signed in to change notification settings - Fork 21.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add form_with to unify form_tag/form_for. #26976
Conversation
# TODO: Documentation | ||
def fields(scope = nil, model: nil, **options, &block) | ||
fields_for(scope || model, model, **options, &block) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dhh added the fields method to do:
form_with(model: @post) do |form|
form.fields(:comment, model: @comment) do |fields|
fields.text_field # ...
end
end
But it reads a bit weird if the passed model
is a collection: fields(:comments, model: @post.comments)
.
Not sure if you intended this to be much more than a fields_for
alias with the don't output ids and classes by default config.
`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 ]
Strips `_for` and requires models passed as a keyword argument.
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.
70bc6cf
to
a4a5945
Compare
Documented Will add docs for |
Brand new world! Forms submit via XHRs by default, woah.
Figured since |
Gives us something to revise when we're redoing the form options helpers. Also deletes the needless tests for the unsupported namespace option.
This should be everything that's needed to prevent the default ids: diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb
index ef114b3..8550ad8 100644
--- a/actionview/lib/action_view/helpers/form_helper.rb
+++ b/actionview/lib/action_view/helpers/form_helper.rb
@@ -703,7 +703,7 @@ def form_with(model: nil, scope: nil, url: nil, format: nil, html: {}, remote: t
html_options[:remote] = remote unless html_options.key?(:remote)
if block_given?
- builder = instantiate_builder(scope, model, options)
+ builder = instantiate_builder(scope, model, options.merge(skip_default_ids: true))
output = capture(builder, &Proc.new)
html_options[:multipart] ||= builder.multipart?
@@ -963,12 +963,7 @@ def fields_for(record_name, record_object = nil, options = {}, &block)
# TODO: Documentation
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)
+ builder = instantiate_builder(scope, model, options.merge(skip_default_ids: true))
capture(builder, &block)
end
@@ -1537,7 +1532,7 @@ def to_model
def initialize(object_name, object, template, options)
@nested_child_index = {}
@object_name, @object, @template, @options = object_name, object, template, options
- @default_options = @options ? @options.slice(:index, :namespace) : {}
+ @default_options = @options ? @options.slice(:index, :namespace, :skip_default_ids) : {}
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
@@ -1840,6 +1835,7 @@ def fields_for(record_name, record_object = nil, fields_options = {}, &block)
# TODO: Documentation
def fields(scope = nil, model: nil, **options, &block)
+ options[:skip_default_ids] = true
fields_for(scope || model, model, **options, &block)
end
diff --git a/actionview/lib/action_view/helpers/tags/base.rb b/actionview/lib/action_view/helpers/tags/base.rb
index cf8a6d6..b8c446c 100644
--- a/actionview/lib/action_view/helpers/tags/base.rb
+++ b/actionview/lib/action_view/helpers/tags/base.rb
@@ -13,6 +13,7 @@ def initialize(object_name, method_name, template_object, options = {})
@object_name.sub!(/\[\]$/, "") || @object_name.sub!(/\[\]\]$/, "]")
@object = retrieve_object(options.delete(:object))
+ @skip_default_ids = options.delete(:skip_default_ids)
@options = options
@auto_index = Regexp.last_match ? retrieve_autoindex(Regexp.last_match.pre_match) : nil
end
@@ -81,9 +82,12 @@ def add_default_name_and_id_for_value(tag_value, options)
def add_default_name_and_id(options)
index = name_and_id_index(options)
options["name"] = options.fetch("name") { tag_name(options["multiple"], index) }
- options["id"] = options.fetch("id") { tag_id(index) }
- if namespace = options.delete("namespace")
- options["id"] = options["id"] ? "#{namespace}_#{options['id']}" : namespace
+
+ unless skip_default_ids?
+ options["id"] = options.fetch("id") { tag_id(index) }
+ if namespace = options.delete("namespace")
+ options["id"] = options["id"] ? "#{namespace}_#{options['id']}" : namespace
+ end
end
end
@@ -154,6 +158,10 @@ def add_options(option_tags, options, value = nil)
def name_and_id_index(options)
options.key?("index") ? options.delete("index") || "" : @auto_index
end
+
+ def skip_default_ids?
+ @skip_default_ids
+ end
end
end
end I think there's likely more code we can skip in the tags. |
Treat both the FormBuilder and FormHelper.
Allright, I think this is ready for review 😁 — I'll merge this tomorrow/tuesday/later depending on the feedback. |
# <input type="text" name="post[title]" value="<the title of the post>"> | ||
# </form> | ||
# | ||
# The parameters in the forms are accessible in controlleres according to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
controllers
# | ||
# 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't think this needs title case. It is referenced as small case in its own documentation.
# | ||
# ==== +form_with+ options | ||
# | ||
# * <tt>:url</tt> - The URL the form submits to. Akin to values passed to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would use "similar" instead of "Akin", since its easier to understand.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Akin seems fine to me.
# 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 post submits to. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
post, put, patch, etc
# * <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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
either comma after "by", or "and" instead of "by".
# 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<tt>:authenticity_token</tt>
# <form action="http://www.example.com" method="post" data-behavior="autosave" name="go"> | ||
# <input name="_method" type="hidden" value="patch" /> | ||
# ... | ||
# </form> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks like wrong output?
# 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, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it, in
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Think it's valid without the comma.
# Writers are considered a nested attributes setter if they're of the | ||
# <tt>*_attributes=</tt> form, e.g. <tt>address_attributes=</tt>. | ||
# | ||
# Depending on the association's reader method return value a different |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
value, a
# <tt>*_attributes=</tt> form, e.g. <tt>address_attributes=</tt>. | ||
# | ||
# Depending on the association's reader method return value a different | ||
# form builder is yielded. For single object returns a one-to-one builder, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
object, returns
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No those words are meant to stay together. We're talking about the return value of the reader method.
# Note +fields+ automatically generates a hidden field to store the record | ||
# ID. For circumstances where this is not needed pass | ||
# <tt>include_id: false</tt> to skip it. | ||
def fields(scope = nil, model: nil, **options, &block) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this needs the same fixes from above.
# | ||
# 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 jquery-ujs, is used. See the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're actually dropping jquery-ujs, maybe we need to tell a different story here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought we we're just dropping the jquery in jquery-ujs. Isn't the plan to keep the ujs behavior around?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, UJS is still there. Just that referencing jquery-ujs seems dated when that won't be the default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Got it 👍
# <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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since our new default is remote: true, I think we need to think this through a little more. Like having authenticity_token be reliant on that value. If you do remote: false, THEN we'll by default include authenticity_token, but otherwise not. And in both cases you can always supply one via the option to overwrite the default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, figured it was just flipping the default. But of course there's repercussions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suspect we should keep the token by default, because otherwise our default forms are broken on JS-less browsers (and anywhere without UJS present). I could maybe see the UJS gem toggling it off by default, but making UJS a hard dep for form submissions to work [out of the box] seems a bit rough.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, I guess that was the reason for config.action_view.embed_authenticity_token_in_remote_forms
as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True. Guess it can just ignore those embedded tokens. I was thinking that remote: true would force the form remote, but of course, if JS is off, that won't be read.
# unnecessary unless you support browsers without JavaScript. | ||
# * <tt>:remote</tt> - Set to true to allow the Unobtrusive | ||
# JavaScript drivers to control the submit behavior, defaulting to | ||
# to an XHR submit. Disable with <tt>remote: false</tt>. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default is true, so needs to flip what this is for.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes!
# JavaScript drivers to control the submit behavior, defaulting to | ||
# to an XHR submit. Disable with <tt>remote: false</tt>. | ||
# * <tt>:enforce_utf8</tt> - If set to false, a hidden input with name | ||
# utf8 is not output. Default is true. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When we have options that are default true, then it's generally better to flip the key name to a negative, imo. So we'd go with skip_enforcing_utf8: true
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was lifted from form_for
. I figured the parity was better when it was a relatively oft unused option. Do you prefer that we rename still?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a new API, so I saw we make the best choices we know how. Keeping parity is not important.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, then we should give that treatment to these options too:
skip_id: true # Was `include_id: false` for nested `fields`
skip_authenticity_token: true # Was just `authenticity_token: false`
local: true # Was `remote: true`. Not sure about this one though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Backing out of the skip_authenticy_token: true
change. Since specifying a custom token via authenticy_token: 'abcdef'
is valid. And would rather not have two options for the same thing.
# 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix when we default token to off with remote: true.
|
||
html_options = html.merge(options.except(:index, :include_id, :builder)) | ||
html_options[:method] ||= :patch if model.respond_to?(:persisted?) && model.persisted? | ||
html_options[:remote] = remote unless html_options.key?(:remote) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All this prep work is only used in the block_given? path, so it should be done there. Also, I think there's more we can do to make this a Composed Method. A lot of mechanics exposed at a high level.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's used in the else
too as the last parameter here too: html_options = html_options_for_form(url || {}, html_options)
But yeah, let me see what I can do with the composition 👍
# Zip code: <%= address_fields.text_field :zip_code %> | ||
# <% end %> | ||
# ... | ||
# <% end %> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mixed use of ERB insertion tags. Should use them for form_with
too.
# <% end %> | ||
# | ||
# When address is already an association on a Person you can use | ||
# +accepts_nested_attributes_for+ to define the writer method for you: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd actually like to kill accepts_nested_attributes_for
in due time. Don't think we should promote it for this new API. Rather, let's just show how to do it by hand in the controller.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yeah! 🔥 Reading through this I thought accepts_nested_attributes_for
stuck out, refrained from doing anything because I didn't have a better idea yet. But yes, let's start down the path toward something better!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For starters, I'd just drop the entire topic for now.
# Note +fields+ automatically generates a hidden field to store the record | ||
# ID. For circumstances where this is not needed pass | ||
# <tt>include_id: false</tt> to skip it. | ||
def fields(scope = nil, model: nil, **options, &block) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems all this doc stuff is repeated from above?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, copied to mirror how fields_for
documents itself (i.e. both as the helper and in the builder). I'll update this to just refer to the fields
helper docs above.
@vipulnsward @dhh thanks for the feedback, I'll look more into this tomorrow 👍 |
`skip_id: true` reads better than `include_id: false` (since the `include_id` default is true).
Since forms are remote by default, the option name makes more sense as `local: true`.
Soon to be bundled in Rails proper, so jquery-ujs is out.
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).
@dhh inverted the option names, removed the nested attributes example and tried to make However, that carried over a lot of baggage from copying over rails/actionview/lib/action_view/helpers/form_tag_helper.rb Lines 833 to 854 in 6d6249b
I think it would be easier to improve the code structure further here in more focused commits after this PR. |
Looks pretty good to me 👍 |
@kaspth Thanks for this PR. You added a <%= fields :comment do |fields| %>
<%= fields.text_field :subject %>
<%= fields.text_field :body %>
<% end %> becomes:
As you see, the HTML has lost the nuance that those two fields are grouped together. In my mind, since you are grouping them together in your code, a better output would be:
since the fieldset HTML tag is used to group related elements in a form:
@kaspth (and @dhh -- original requester for this PR)… what is your opinion on this? Could the |
I'd rather just defer any extra markup onto people themselves, so they have the complete freedom on how to design their forms. |
# 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This means when passing :url
option, I would have to explicitly attach the format to the url as well? Wouldnt it be nicer to allow url without an explicit format and allow :format
option too?
This doesn't seem to be in the Rails guides, is it? |
form_with was added to replace form_for rails/rails#26976
Use form_with for reg edit view form_with was added to replace form_for rails/rails#26976 Extract helpers for front door form Extract FormDoorForm component for registration Refactor user forms to FrontDoorForm
Ref #25197
form_tag
andform_for
serve very similar use cases. ThisPR unifies that usage such that
form_with
can output justthe opening form tag akin to
form_tag
and can just work witha url, for instance.
form_with
by default doesn't attach a class or id to the form.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 byfields
).Pending in this PR:
fields
DSLremote: true
form_with_test.rb
.Later work up for grabs once this is in:
form.text_field :name, 'override'
form.text_field :not_implemented_on_model
form_tag
andform_for
throughform_with
(extract the implementations to a common object and set configs)