Easy Rails Undo With Memento
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:
- the user interface of the browser’s JS confirmation box most probably does not match the UI of your application
- it is easy to annoy users by requiring them to click twice each and every time they do something
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:
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!