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.

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.

html

<ahref="/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.

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.

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.

Finally we need to modify the “destroy” link so that it points to the delete action.

/app/views/products/index.html.erb

<%= 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.

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.

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.

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.

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.

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.