Friday, 11 April 2008

Button-up your actions

Google-safing dangerous actions is second-nature by now. Any action that will change the state of a resource gets hidden behind a button. Anything else is safe as a link. But a mix of links and buttons looks ugly to the user. We want a consistent-looking interface so the user knows that "clicking on one these does something to my Order". The user doesn't need to be confused by some wishy-washy interface representation of of the level of dangerousness of their click. That's one piece of information too many for somebody that just wants to Get Things Done. So our choice is:

  1. Expose our unsafe operations - and just wait for the day when a crawler comes in and deletes all our users' orders. or
  2. Hide the safe actions behind buttons - and fiddle a bit to match the Rails RESTful interface.

We're unlikely to get crawlers come in past our login screen and our users are exceptionally unlikely to crawl for a copy of the site... but un-buttoning our dangerous actions will leave us with our pants down - and that's surely gonna bite us where it hurts someday. It's not something I'm comfortable with doing. So we've opted for the latter approach.

That leaves us with making the buttons play nice with Rails' RESTful interface. Normally this is an easy operation. Just like you have a delete button that requires you to pass in the DELETE method, you can pass in the GET method for any get-oriented actions thus:

  <%= button_to('Edit', edit_user_order_path(@user, @order), :method => :get) -%>

Magical disappearing parameters

For a long while the above approach worked just fine... but then we came across an instance where we needed to add in extra parameters. We wanted to pass along a flag that tells the controller we're using the wizard interface, rather than the standard one. It lets us rediect the user to the next step in the process, rather than the usual confirmation screen. So I tried to create the buttons looking something like below:

  <% button_opts = {:user_id => @user.id, :id => @order.id, :wizard => true} -%>

  <%= button_to('Edit', edit_user_order_path(button_opts), :method => :get) -%>
  <%= button_to('Delete', user_order_path(button_opts), :method => :delete) -%>

For the POST and DELETE buttons this parameter came through fine. For the GET-buttons, however, the flag was conspicuously absent from the params hash. :(

Checking the source code gave me something like below (note: cleaned up for clarity):

<form method="post" action="/users/23/orders/42?wizard=true">
  <input name="_method" type="hidden" value="delete" />
  <input  type="submit" value="Delete" />
</form>
<form method="get" action="/users/23/orders/42/edit?wizard=true">
  <input type="submit" value="Edit" />
</form>

As you can see - they both carry the wizard flag in the URL - so there's nothing wrong with how the parameters are getting passed in at the front. But when you click on the Edit button - the flag is missing from the URL in my browser, and from the Parameters hash (displayed in the console)... so it's somehow getting dropped along the way.

Gazing deeper into the source code, you can see that the delete form still has method="post". It passes the DELETE via the hidden "_method" field. By comparison, the edit form has replaced the POST method with GET.

This seems to be the major difference. Somehow, the standard GET form isn't behaving nicely. I don't know why this bug is occurring - it could be a browser-bug, or Rails - and it really doesn't matter. I need to make buttons that am certain will work.

Luckily, we can use our knowledge of the hidden "_method" field to our advantage. The following code forces the get into the hidden field rather than the form. It works fine and the flag shows up happily on the other side.

  <% button_opts = {:user_id => @user.id, :id => @order.id, :wizard => true} -%>

  <%= button_to('Edit', edit_user_order_path(button_opts.merge('_method' => 'get'))) -%>
  <%= button_to('Delete', user_order_path(button_opts), :method => :delete) -%>

11 comments:

Benjamin said...

Hey Taryn.
Found your site on Cheb.com.au - nice blog and nice post. I just started my foray into Ajax and R.O.R. Should be good fun..

Will be back.

Cheers,
Benjamin

Taryn said...

Hey thanks!
Good luck with your RoR adventure. I've found it just an awesome language/framework to play with. :)

Cheers,
Taryn

Matthew said...

As another solution to your problem, could you put the wizard parameter in as a hidden form field instead of in the URL? i.e.

<input name="wizard" type="hidden" value="true" />

Maybe that will get the wizard flag into the params hash irrespective of the HTTP method (or the _method field) for the form.

Taryn said...

Hi Matthew,

That'd work just fine if it was a form - but these are buttons. I'd have to literally write out a form - and I'm too lazy to do that ;)

More seriously though - I'll give it a try and see, just to know if it works. If it does I can easily abstract it into a hand-rolled button method.

I'm dubious, though, given the propensity for it to ignore perfectly-good parameters on the query string :P
But you never know - and it's always worth a try ;)

Cheers,
Taryn

Les said...

Thanks for that workaround, Taryn. It helped me out of a bind. I never would have thought of it.

I have to disagree, though, with your comment that it's not really important whether it's a browser bug or a rails bug. If it's a rails bug, we owe it to the community to report it, at least, and if we have the skills, to propose a patch. I think that's the least we can do for this awesome platorm, and I look forward to the time that my skills are strong enough that I can fix problems like this for everyone!

Of course, if it's a browser bug... well I'm mystified how something so basic could be a browser bug, even though it behaves like one.

Taryn said...

Hi Les,

I agree with you re: sending fixes back to the Rails community. I think what I said might have been expressed badly.

When I came up with this solution, I was time-pressed and needed an immediate fix. With a fixed production release looming, we didn't have time to search out the real cause of the bug and submit it to the community, we just had to get it working.

To that end, it didn't matter what caused the bug, just what would fix it for us at that time.

No doubt I should have followed-up at a later date and submitted it to the community - but it looked like that had already been done by others, so I wasn't adding much by saying "me too".

Clearly, in the long-run it's important to make Rails better, and that can be done by finding and reporting on Rails source bugs and hacking on the source code. But IMO it can also be done through offering quick-fix solutions I've found along the way too. ;)

Cheers,
Taryn

Bill said...

Very helpful post, thanks much for sharing!

Dan said...

This is not working for me. I'm using Rails 3 so wondering if that's why. I get "_method=get" as a query parameter on the URL rather than as a hidden field.

Is this supposed to work in Rails 3? Is there a better (or indeed any) way to pass arbitrary parameters in a button_to in Rails 3?

Also I'd love to know the rationale for just stripping the parameters. I'll acknowledge the Rails folks are probably doing for a reason but I'd love to know what that reason is!

Taryn said...

Hi Dan,

Rails 3 was released in Aug 2010. If you look at at the top of the post you'll see that this blogpost was published in 2008.

There's a lot of legacy blog articles out there on the web, so it's a good idea to get into the habit of checking posting dates and assuming that the author probably hasn't updated their article for new version of rails unless specifically mentioned.

Good to ask, though. :)

I'm not certain what you're asking about the rationale behind stripping parameters - what would you like to know?

As to passing params button_to - there should be a way.

Looking at the apidock page on button_to for Rails 3:
http://apidock.com/rails/v3.0.0/ActionView/Helpers/UrlHelper/button_to

I see no significant change in how the options are passed through the params.

It may be that the "_method" hack mentioned in the article is no longer relevant to Rails 3.

Have a go just passing :get as the :method option and go have a look at the HTML that is generated. Have a look at the broken code that I've got in the article, and see if the problem has been fixed... if so - you can just use :method instead of _method.

If not - you may need to replace the button_to with an actual form-tag instead. Works exactly the same way - just not as quick to write.

Let me know how it goes in case anybody else has the same problem.

Cheers,
Taryn

DB said...

Rails 3 still has this same "bug" (or feature?).

Taryn said...

Thanks DB - good to know.