Skip to content
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

Merged
merged 16 commits into from
Nov 21, 2016
Merged

Conversation

kaspth
Copy link
Contributor

@kaspth kaspth commented Nov 5, 2016

Ref #25197

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 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 by fields).

Pending in this PR:

  • Add new fields DSL
  • Default to remote: true
  • Remove commented out tests in the form_with_test.rb.
  • Basic documentation
  • Changelog entry 😁

Later work up for grabs once this is in:

  • Add overridable field values, e.g. form.text_field :name, 'override'
  • Allow fields not on the model, e.g. form.text_field :not_implemented_on_model
  • Revisit form field defaults e.g. stop outputting ids and classes on fields by default
  • Revisit form options DSL methods
  • Implement form_tag and form_for through form_with (extract the implementations to a common object and set configs)

@kaspth kaspth added this to the 5.1.0 milestone Nov 5, 2016
@kaspth kaspth self-assigned this Nov 5, 2016
# TODO: Documentation
def fields(scope = nil, model: nil, **options, &block)
fields_for(scope || model, model, **options, &block)
end
Copy link
Contributor Author

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.
@kaspth
Copy link
Contributor Author

kaspth commented Nov 6, 2016

Documented form_with and had one hell of a time doing it! Really got to know the existing API and where the new one differs, plus in what way it does.

Will add docs for fields tomorrow.

Brand new world! Forms submit via XHRs by default, woah.
@kaspth
Copy link
Contributor Author

kaspth commented Nov 6, 2016

Figured since form_with already doesn't output id and class by default, we may as well rejigger the remaining default at the form level: remote. It's now on by default!

Gives us something to revise when we're redoing the
form options helpers.

Also deletes the needless tests for the unsupported namespace
option.
@kaspth
Copy link
Contributor Author

kaspth commented Nov 8, 2016

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.
@kaspth
Copy link
Contributor Author

kaspth commented Nov 13, 2016

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
Copy link
Member

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
Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor Author

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.
Copy link
Member

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.
Copy link
Member

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.
Copy link
Member

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>
Copy link
Member

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,
Copy link
Member

Choose a reason for hiding this comment

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

it, in

Copy link
Contributor Author

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
Copy link
Member

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,
Copy link
Member

Choose a reason for hiding this comment

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

object, returns

Copy link
Contributor Author

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)
Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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.

Copy link
Contributor Author

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.
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

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>.
Copy link
Member

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.

Copy link
Contributor Author

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.
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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.

Copy link
Contributor Author

@kaspth kaspth Nov 15, 2016

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.

Copy link
Contributor Author

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.
Copy link
Member

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)
Copy link
Member

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.

Copy link
Contributor Author

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 %>
Copy link
Member

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:
Copy link
Member

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.

Copy link
Contributor Author

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!

Copy link
Member

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)
Copy link
Member

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?

Copy link
Contributor Author

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.

@kaspth
Copy link
Contributor Author

kaspth commented Nov 14, 2016

@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).
@kaspth
Copy link
Contributor Author

kaspth commented Nov 20, 2016

@dhh inverted the option names, removed the nested attributes example and tried to make form_with a bit more composed.

However, that carried over a lot of baggage from copying over html_options_for_form

def html_options_for_form(url_for_options, options)
options.stringify_keys.tap do |html_options|
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 if html_options.delete("remote")
if html_options["data-remote"] &&
!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
end
end
— which was the most sensible way to do it that I could see.

I think it would be easier to improve the code structure further here in more focused commits after this PR.

@dhh
Copy link
Member

dhh commented Nov 21, 2016

Looks pretty good to me 👍

@claudiob
Copy link
Member

@kaspth Thanks for this PR.

You added a fields method to Action View which lets you group multiple fields together, so that:

<%= fields :comment do |fields| %>
  <%= fields.text_field :subject %>
  <%= fields.text_field :body %>
<% end %>

becomes:

<input type="text" name="comment[subject] id="comment_subject">
<input type="text" name="comment[body] id="comment_body">

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:

<fieldset>
  <input type="text" name="comment[subject] id="comment_subject">
  <input type="text" name="comment[body] id="comment_body">
</fieldset>

since the fieldset HTML tag is used to group related elements in a form:

Grouping related form controls makes forms more understandable for all users, as related controls are easier to identify. It also makes it easier for people to focus on smaller and more manageable groups rather than try to grasp the entire form at once.

Grouping needs to be carried out visually and in the code, for example, by using the <fieldset> and <legend> elements to associate related form controls.

@kaspth (and @dhh -- original requester for this PR)… what is your opinion on this?

Could the fields method introduced here have at least an option to wrap the fields in a <fieldset>?

@kaspth
Copy link
Contributor Author

kaspth commented Mar 14, 2017

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.
Copy link
Contributor

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?

@aledalgrande
Copy link
Contributor

aledalgrande commented Mar 29, 2018

This doesn't seem to be in the Rails guides, is it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants