Jens Krämer

Easy Rails Undo With Memento

 |  ruby, rails

Any application that allows users to delete things or carry out otherwise ‘dangerous’ actions needs some kind of safety net to protect users from the consequences of errors (be it due to lack of coffee, too big fingers on a touch screen or bad hand-eye coordination when using a pointing device).

The ‘Are You Sure’ Problem

For Rails developers the implementation of this safety net often means adding data-confirm="Are you sure?" to any such dangerous links, causing a JavaScript confirmation box to pop up whenever the link is clicked. While this is easy to implement and does the job, it has some drawbacks:

A possible solution to the UI problem (and to a certain degree to the ‘do not bug me’ problem as well) of course is to implement your own confirmation dialog. A custom JS overlay is easily built, and there you could even implement a “Don’t ask me again” checkbox, allowing users to remove the safety net.

But there’s another approach which both gets out of the way and offers a safety net just in case:

Offer an Easy Way to Undo the Last Action

A nice implementation of this approach can be found on Android phones:

Undo in Android's stock Messaging app

That’s the stock Android Messaging app. Whenever you archive a conversation with a left-swipe, that conversation disappears instantly, and a temporary message at the bottom of the screen tells you what happened, along with an Undo button.

So if you deleted a conversation by accident, it’s just a single tap to restore it. In the normal case (you intentionally deleted it) you can just ignore the message and go on with your business.

Undo in a Rails Application

If this were a Rails app, it could easily achieve similar behaviour by displaying a flash message saying ‘Conversation archived. Undo’, with ‘Undo’ being a link to some controller action that just does this.

A quick search turns up several Rails extensions that have the potential to help with this:

Out of these, PaperTrail and Vestal Versions implement the more general task of adding versioning to ActiveRecord models. That is, unless told otherwise, they keep a log of all changes done to any records of these versioned models. With the help of this version history they allow to revert records to any of their previous states.

There’s a Rails cast (text version) showing how to implement simple Undo functionality with PaperTrail, and also an older one about model versioning (text version) which talks about Vestal Versions.

So yes, certainly you can roll your own Undo functionality with the help of one of these gems.

But let’s look at option number three…

Turns out Memento is built specifically for the Undo use case. Under the hood it keeps track of model changes as well, but it does not issue any version numbers nor make it particularly easy to roll back a single record to an older version. Instead, Memento tracks model changes in the context of a session and keeps multiple changes, which might potentially touch different records from different models, grouped together.

Memento sessions are kept in a separate database table and as such can be easily identified by their primary key. So all we need to do is attach the session id to our Undo link. Then if the user decides to Undo, it’s as simple as finding that session record and calling undo on it.

As you can see this approach makes the implementation of a general Undo action that can handle even complex actions spanning a set of record changes very easy.

Sounds like the ideal candidate. Despite being around for quite a while, Memento appears to be not very well-known or widely used. But since it’s made by yolk, the people behind mite., which offers Undo for quite some time now, I still was confident it would work as advertised and gave it a try.

Rails Undo With Memento

Setup

Suppose we have two related models, Conversation and Message. To tell Memento to track these, just add a call to memento_changes to them:

class Message < ActiveRecord::Base
  belongs_to :conversation
  memento_changes :destroy
end

class Conversation < ActiveRecord::Base
  has_many :messages, dependent: :destroy
  memento_changes :destroy
end

For now we only want to track the destruction of records, hence the :destroy argument. Leave it out to track creates and updates as well.

There’s also a migration which can be generated via rails g memento_migration. That is, after you fix the generator for current Rails versions.

Recording a Set of Changes

To actually record a set of changes that can later be rolled back as a whole, you have to wrap them into a block in your controller:

def destroy
  memento do
    @conversation.destroy
  end
  flash[:notice] = 'Conversation deleted'
end

Under the hood this will create a Memento::Session with a number of Memento::States attached, one for each destroyed record. Destruction of the Conversation will destroy any attached Messages as well, and since we also introduced the Message model to Memento, those implicit deletions will get recorded, too. In addition to that, the X-Memento-Session-Id HTTP header will be set to contain the id of that session. Note that Memento also wants to connect its sessions to the current user which it tries to get hold of via a current_user call.

Setting the session id in an HTTP header seems a bit weird at first but comes in handy when you have the Undo link rendered by a client side script.

To keep it simple we will for now just render a simple ‘Undo’ link whenever that header is set:

<%= flash[:notice] %>
<% if id = response.headers['X-Memento-Session-Id'] %>
  <%= link_to '(Undo)', undo_path(id), method: :post %>
<% end %>

Undo!

Since Undo is a function with the potential to be used in various places of an application, it’s a good idea to create a separate controller for that.

# routing example:
# post 'undo/:id' => 'undo#create', as: :undo
class UndoController < ApplicationController
  def create
    undo_session = current_user.memento_sessions.find(params[:id])
    result = undo_session.undo
    if result.success?
      flash[:notice] = "Undone!"
    else
      flash[:alert] = "Undo failed :("
    end
    redirect_to :back
  end
end

As you can see I retrieve the Memento session from current_user. This serves the simple but important purpose of only allowing users to undo their own actions. Just add has_many :memento_sessions to your User model.

For the sake of simplicity our undo action just redirects back to the originating page. If you go about implementing Undo in your own app you will soon find out that actually handling the Undo case in the user interface can easily become the hardest part. I have some ideas here as well, but I’ll leave that, and some Memento customizations I did along the way, to a later post.

Do you know of any other Rails undo solutions? Tell me in the comments!

Comments

You can use Markdown here.

For the sake of spam checking any data you submit, including your IP address, will be transferred to the US based Akismet web service (akismet.com). If that's not acceptable for you, you can also reach me by other means.