#77 Destroy without JavaScript (revised)
- Download:
- source codeProject Files in Zip (93.2 KB)
- mp4Full Size H.264 Video (18.6 MB)
- m4vSmaller H.264 Video (9.52 MB)
- webmFull Size VP8 Video (13 MB)
- ogvFull Size Theora Video (21 MB)
If we look at almost any Rails application we’re likely to find a “Destroy” link to remove an item. These links require JavaScript to work.
We have JavaScript enabled in our browser so if we click one of the “Destroy” links we’ll be asked if we’re sure we want to delete that item. When we confirm that we do the item is deleted. If, however, we disable JavaScript in the browser and try to destroy a product we’ll be taken to that product’s page instead and this behaviour can be confusing for users.
Nearly all users have JavaScript enabled so we might not consider this to be a problem. What’s more likely, however, are cases when the JavaScript doesn’t load properly. A user may be on a slow or flaky connection and there are also edge cases when, even with JavaScript enabled, we’ll see an item’s page rather than deleting it. One of these cases can be triggered if we right click on a “destroy” link and open the link in a new window or tab from there. In these cases the JavaScript won’t be fired and we’ll see the page for the item we tried to delete.
How a Destroy Link Works
In this episode we’ll demonstrate a couple of ways to make this behaviour degrade gracefully. Before we do that we’ll get a better understanding of how a “destroy” link works.
We can see one of these links in the ProductsController
’s index
view template.
<%= link_to "Destroy", product, method: :delete, data: {confirm: "Are you sure?"} %>
This link points to that product which will normally trigger the show action. As we’ve set the method option to delete
a DELETE request will be made instead which will trigger the destroy
action. We also use the data
hash to supply a confirm
option so that a confirmation message is shown when the link is clicked. In earlier versions of Rails we could pass a confirm
option without the data
hash and while this still works in Rails 3 this will be deprecated in Rails 4 so we should use the data hash at all times.
If we view the source of this page we’ll see the generated HTML for the link. This is a standard anchor element with data-confirm
and data-method
attributes.
<a href="/products/1" data-confirm="Are you sure?" data-method="delete" rel="nofollow">Destroy</a>
These attributes on their own don’t do anything special but one of the JavaScript files that’s included, jquery_ujs.js
, has code that detects these links and turns them into DELETE requests when they’re clicked. Without this JavaScript these links are treated like every other and just make GET requests. One of the easiest solutions to this problem is to change the link into a button by using button_to
. This will also create a form that is capable of doing more than just making a GET request.
<%= button_to "Destroy", product, method: :delete, data: {confirm: "Are you sure?"} %>
Now when we reload the page each product has a “destroy” button instead of a link.
These don’t look as pretty as the links but we can go a long way to improving this with some CSS.
form.button_to { display: inline; margin: 0; padding: 0; div { display: inline; } input[type=submit] { margin: 0; padding: 0; -webkit-appearance: caret; background: none; border: none; font-size: inherit; font-family: inherit; cursor: pointer; text-decoration: underline; color: #0000FF; } }
This CSS rule targets the button_to
class which is assigned to any buttons generated with the button_to
method. If we reload the page now the button will look just like the link we had before. Of course you can style the button to suit the needs of your application. If we click our new button to delete a product while we have JavaScript disabled it will work as we expect and the product will be removed. We won’t, however, be asked if we’re sure if we want to remove the product so this solution may not be ideal for you.
Adding a Confirmation Step
The simplicity of this button_to
approach makes it easy to implement but if having a confirmation step when JavaScript isn’t enabled is important we could implement an undo system like the one we covered in episode 255. There’s another approach that we could use and we’ll show you that next. It’s a little more complicated but it means that we can return to using link_to
instead of a button.
<%= link_to "Destroy", product, method: :delete, data: {confirm: "Are you sure?"} %>
Next we’ll add a new action to the ProductsController
called delete
. This sits nicely alongside the new and edit actions and will ask for confirmation for the destroy behaviour.
def delete @product = Product.find(params[:id]) end
In this action we fetch the product which has been selected for deletion. We’ll need a template to go with this action which will ask the user if they’re sure they want to delete that item.
<%= form_for @product, html: {method: :delete} do |f| %> <h2>Are you sure you want to delete this product?</h2> <p><%= f.submit "Destroy" %> or <%= link_to "cancel", products_path %></p> <% end %>
This form simply makes a form for the product with a method of DELETE which will trigger the destroy
action when the form is submitted. Alternatively the user can click the “cancel” link to return to the list of products. We still need a way to access this action as it’s not set up in our routes. We’ll modify the routes file now to add it.
Example::Application.routes.draw do resources :categories resources :products do member { get :delete } end root to: 'products#index' end
Finally we need to modify the “destroy” link so that it points to the delete
action.
<%= link_to "Destroy", [:delete, product] %>
Note that we’ve removed the method
option and the data
hash so that the link behaves the same way whether JavaScript is enabled or not. Now when we click the “destroy” link for an item we’ll be taken to our new confirmation screen and clicking the button will delete the item.
What if we only want to fall back to this behaviour when JavaScript is disabled and stick to the usual confirmation alert otherwise? There are a couple of ways that we can do this. One option is to detect the “destroy” link with some jQuery code and strip off the /delete
portion of the URL and add back the data attributes. The other approach doesn’t require any jQuery and brings back the data attributes in the link. We’ll implement this second solution in our application and start by putting these attributes back into our link.
<%= link_to "Destroy", [:delete, product], method: :delete, data: {confirm: "Are you sure?"} %>
In our routes file we’ll add another route to listen for a DELETE request to the delete
action and map it to the destroy
action.
Example::Application.routes.draw do resources :categories resources :products do member do get :delete delete :delete, action: :destroy end end root to: 'products#index' end
This doesn’t really follow REST but our delete
action isn’t true REST either so we’ll go for what’s simple and practical in this situation. Now if we click a “destroy” link with JavaScript enabled we’ll see the JavaScript confirmation but if JavaScript isn’t enabled or if we open the link in a new window it will fall back to our delete
action where we can confirm the deletion on a separate page.
Applying This Approach to All Resources
Our approach works well but what if we want to apply it to another resource? Our application also has a page that displays a list of categories which can be edited or destroyed so how do we add this same behaviour here? While we could copy the delete
action and its template into the other controller this would create a lot of duplication which it’s best to avoid. Instead we’ll make the delete
action more generic so that it’s not specific to a product. This means that we’ll also need to change the view template, too.
<%= form_tag request.url, html: {method: :delete} do %> <h2>Are you sure you want to delete this record?</h2> <p> <%= f.submit "Destroy" %> <% if request.referrer.present? %> or <%= link_to "cancel", products_path %> <% end %> </p> <% end %>
Now instead of using form_for
we use form_tag
and the form will send a DELETE request to the form’s URL which given the modifications we’ve made to the routes file will trigger the destroy
action. Note that we only display the “cancel” link if a referrer is present and this link points to the referring page.
To share this template across all controllers we’ve created a new application directory under /app/views
. We have an ApplicationController
that other controllers inherit from and similarly we can put view templates in this new directory that will also be available to all controllers. To use this we need to modify any “destroy” links so that they point to this shared delete
action.
<%= link_to "Destroy", [:delete, category], method: :delete, data: {confirm: "Are you sure?"} %>
Next we’ll just need to modify the routes. We could duplicate the block we wrote for the products
resource for the other resources but again this will lead to duplication so instead we’ll modify the way that resources
works so that it automatically adds the extra routes. We’ll do this inside a new initializer file that we’ll call delete_resource_route
.
module DeleteResourceRoute def resources(*args, &block) super(*args) do yield if block_given? member do get :delete delete :delete, action: :destroy end end end end ActionDispatch::Routing::Mapper.send(:include, DeleteResourceRoute)
In this file we define a DeleteResourceRoute
module and include this module in the Routing::Mapper
class which is where the resources
method is defined. In our module we override this method. In our version we call super to get the behaviour from the overridden method and pass in a block to this. Inside the block we yield
to any block that’s passed to resources then add our own behaviour to add the delete
action for both GET and DELETE requests. With this in place any call to resources
will automatically inherit this additional functionality so we no longer need to specify it in our routes file.
Example::Application.routes.draw do resources :categories resources :products root to: 'products#index' end
As we’ve added an initializer we’ll need to restart our Rails app. Once we have when we try to destroy a category we’ll see the JavaScript confirmation box. If we disable JavaScript and try again the app falls back and degrades gracefully and shows us the delete
template.
This entire process might have seemed rather complicated but in the end we only need three things: the initializer file to add the delete
routes, the delete
template
in the /app/views/application
directory and to modify any “destroy” links to point to the delete
action. With these in place we’ll have graceful degradation for destroy links. If you want something simpler there’s always the button_to
approach instead.