Part 4 of n of the series “Never write HTML, Javascript, or CSS again”
Let’s look at a simple form_for:
<%= form_for @user do |f| %>
<%= f.label :name %>:
<%= f.text_field :name %><br />
<%= f.submit %>
<% end %>
Interestingly, the default behavior does not require a route for the
button (which by default is labelled “Create User”) to work. When we click
on this button, Rails calls the “create” method:
class UsersController < ApplicationController
def create
# ... do something
end
end
That’s pretty interesting. Now let’s tweak the form_for
a
bit by introducing a desired action:
<%= form_for @user, :url => { :action => "upload" } do |f| %>
<%= f.label :name %>:
<%= f.text_field :name %><br />
<%= f.submit %>
<% end %>
Now that we’re specifying a URL (and what, you may ask, does this have to do
with the action?) we now need a route. If we don’t provide a route, we get
this message:
No route matches {:action=>"upload", :controller=>"home"}
Interesting again, because Rails is also telling us the controller that it
expects to handle the route “/upload”. Because I created the view for this
demo page in home and therefore we also have a HomeController defining the page
(I called it “test2”):
class HomeController < ApplicationController
def test2
@user = User.new()
end
end
and, the accompanying route:
get "/test2" => "home#test2"
we can assume that Rails is making a stab at the controller because that’s
the controller that invoked the page. Rails is making the assumption that
the controller should be HomeController
. Interestingly, it did NOT make
this assumption earlier.
So, let’s provide this route:
post "/upload" => "home#upload"
and the accompanying method (in HomeController):
def upload
# do something
end
and all is well.
But we don’t want this route vectoring off to the HomeController. We
want it to go to the UsersController like it was before we started finagling
with the URL. So we provide an override:
<%= form_for @user, :url => { :controller => "users", :action => "upload" } do |f| %>
<%= f.label :name %>:
<%= f.text_field :name %><br />
<%= f.submit %>
<% end %>
and yes, we would expect to get:
No route matches {:controller=>"users", :action=>"upload"}
so once again, we finagle the route:
post "/upload" => "users#upload"
and write the method in UsersController:
class UsersController < ApplicationController
def upload
# Do something
end
end
and again all is well.
But let’s look at the generated HTML now. It looks like this:
<form accept-charset="UTF-8" action="/upload" class="new_user" id="new_user" method="post">
<label for="user_name">Name</label>:
<input id="user_name" name="user[name]" size="30" type="text" /><br />
<input name="commit" type="submit" value="Create User" />
</form>
Notice there is nothing that indicates the controller in the markup.
So, what Rails is doing, when it parses the form_for tag, is it is matching
what is on the right-hand side of the route action with what you specify in the
:url => { }
hash. Now, wait a minute. This seems
unnecessarily invasive. Why should form_for care what controller is
handling the action route? It’s not like you can specify two different
controllers for the same route! Ironically, if you do, Rails doesn’t
throw an exception (at least 3.2.17, which is what I’m testing with) — in
fact, it will always use the first matching route’s controller and method
mapping!
Now, let’s make matters more interesting. The Airity DSL isn’t going to
generate a form_for
Rails tag, it will be generating the HTML form
tag, like in the HTML example above. And since there’s no controller being
specified in the HTML, the controller that receives the “action” will be
determined by the route. If I say home#upload
, then the HomeController#upload
function is called, if I say "users#upload
, then the UsersController#upload
function is called.
So why does Rails introduce this complexity of making an assumption of what
the default route is (and making an incorrect assumption) and secondly,
requiring a controller hash, which it then enforces that the route is mapped to
the same controller? These conflicts that can occur between the route
definition and the form_for tag can make for (quite unnecessary, in my opinion)
heaps of confusion. First, it shouldn’t make any assumptions, second, it
should require a controller hash, and third, it should just let the route
specify the controller.
However!
In my DSL, why not define the controller and method that should handle the
button click? (Notice we’re dealing with the form
tag, not the input
button — the page that receives the data is determined by the action
attribute in the
form
tag.) Why should I have to edit the routes.rb file
when I create forms in the DSL? This adds complexity and unnecessary
decoupling of action with the controller and method that handles the action.
Unfortunately, the solutions are non-trivial. The basic solution, the “catch
all“, can have undesirable side-effects. A
more robust solution by Michael Lang is Rails 4 dependent — in other words, it looks like
that there is no common solution (other than the “catch all”, that is Rails
version independent.
Then I stumbled across
this post which looks to me like it would be Rails agnostic, as it is adds a
route to the routes table with Rails.application.routes.draw do…end.
Looking at this implementation, it looks very similar to what Michael Lang’s
Rails4 dynamic router is doing. So let’s give it a try.
In the DSL, I want to specify the Sign In action:
html_dsl.form("user", {action: 'sign_in'}) do
and after modifying the DSL code a bit to handle this new hash, we can see
the generated HTML:
<form id="new_user" action="/sign_in" class="new_user" method="post">
and indeed, clicking on the Sign In button gives us the expected routing
error:
No route matches [POST] "/sign_in"
Now we try to specify the handler, let’s hard-code it for the moment:
def sign_in_markup(html_dsl, fz_dsl, styles)
Rails.application.routes.draw do
post "/sign_in" => "users#sign_in", :as => "users_sign_in"
end
html_dsl.div({id: 'sign_in_page', styles: ['display: none']}) do
html_dsl.form("user", {action: 'sign_in'}) do
....
what you’ll notice is that this initially works. But do a refresh on
the home page, and you get:
No route matches [GET] "/"
Whoops! What happened to the routes that were defined in
routes.rb?
If we add the mysterious
Rails.application.routes.disable_clear_and_finalize = true
before the “do”
statement, lo-and-behold, it works!
That was remarkably simple, yet a few questions remain — how exactly is this
working, are there unintentional side-effects, does this work with Rails 4, and
are these route additions cumulative, or do they replace any existing
definition?
I’m sure these questions will be explored in more depth at some point!