How to add a Google Docs-style edit history feature to a Rails app

Jaye Hackett
FutureGov
Published in
6 min readJun 22, 2020

The revision history feature in Google Docs is pretty useful. With the ability to intuitively see how versions differ from each other and restore old versions in a couple of clicks, it’s a big enabler of collaborative work.

The legacy apps we’re used to replacing might include a rudimentary edit history (item was edited at X time by Y user) but nothing that meets modern raised expectations.

Here, we’ll explore how to add a feature like this to a service directory app built in Rails. Doing the hard work to make this simple for users can enable more collaborative ways of working.

We’ll cover:

  • the model changes needed to capture and restore edits
  • the routes, controllers and views to make an interface for users to review
  • possible extra enhancements
An example of the kind of interface we could build with this version history feature.

Before we start, a note: gems like paper_trail can do 80% of what we describe here, so if you can use them, do. We’ve found that we need more fine-grained control in how associations were captured and restored.

Models

There are three relevant models:

  • service — which could really be any model that we want to capture an edit history of. We’ll capture some associations of this model too
  • user — who can log in and make changes to services
  • snapshot — which preserves a service at a particular instant in time. Whenever a service is changed, a new snapshot is automatically captured
Service, user and snapshot model associations.

You can generate snapshots with a terminal command like this:

rails g model Snapshot object:json action:string user:references service:references

The representation of the service will be stored in the object column. We’ve used the JSON data type, which you might not have available unless you’re using a postgres database. Text can also work.

At the moment, our models/snapshot.rb file looks like this:

class Snapshot < ApplicationRecord
belongs_to :user, optional: true
belongs_to :service
end

We’re making the association with a user optional because there could be system-generated changes that can’t be traced back to a particular user. We also need to make sure we include a has_many :snapshots line in both models/user.rb and models/service.rb.

Next, we need to write the logic that builds and captures a snapshot whenever a service is changed. We can use active record callbacks to make this super-easy. Add this to models/service.rb:

after_save :capture_on_savedef capture_on_save
if
self.snapshot_action
capture
(self.snapshot_action)
elsif self.id_previously_changed?
capture
("create")
else
capture
("update")
end
end
def capture(snapshot_action)
new_snapshot = Snapshot.new(
service: self,
user: Current.user,
action: snapshot_action,
object: self.as_json(include: [
:taxonomies,
])
)
new_snapshot.save
end

Let’s walk through it:

  • The capture function builds a new snapshot associated with the currently logged in user and the service that has just been edited.
  • It also stores a string with a reference to the action that has just been done. It tries to automatically detect whether something was created or saved, but also accepts a manual string so that custom actions can be recorded (eg. “discarded”, “approved” or anything else you can imagine).
  • A JSON representation of the Active Record object at the current moment is pulled out. We can lump in nested association data by using the include option. Here, we’re including a has_many association called :taxonomies.

Capturing the currently logged in user

Although most login gems (including devise) provide a current_user variable in the controllers, we need to do some extra work to make that value available in our models. What we did is adapted from this solution.

First, we made models/current.rb:

module Current
thread_mattr_accessor :user
end

And we can modify controllers/application_controller.rb to make sure that the value stays up to date:

class ApplicationController < ActionController::Base
around_action :set_current_user
def set_current_user
Current.user = current_user
yield
ensure
Current.user = nil
end
end

Now, in our models we can access the currently authenticated user as:

Current.user

Neat!

Routes and controllers

Now we’re capturing all the right information, let’s make it browsable to users in the front-end of our app. There’s an unlimited number of ways to do this, and it depends on the way users are going to want to use your app. Ask questions like:

  • are people going to want to compare versions with each other, or just to the current live version
  • is your data flat (like a blog post), or is it deeply nested (like surveys with multiple questions, each with multiple options)
  • how often is the data likely to change

Regardless, the first step is likely to be adding some new nested routes to config/routes.rb:

resources :services do
resources :snapshots
end

This lets us use URLs like /services/100/snapshots. We can add something like this to controllers/snapshot_controller.rb:

class SnapshotsController < ApplicationController
before_action :set_service
def index
@snapshots = @service.snapshots.order(created_at: :desc)
end
def show
@snapshot = @service.snapshots.find(params[:id])
@live_object = @service.as_json(include: [:taxonomies])
end
private def set_service
@service = Service.find(params[:service_id])
end
end

Views

Many good edit history features make comparisons super-clear by highlighting exactly what’s changed between versions. Inspecting commits on Github are a good example.

Diffy is a particularly good gem for this. It produces ready-to-display HTML output and uses red and green to show changes by default. It can add plus and minus symbols to improve the accessibility of the output.

In views/snapshots/show.html.erb, something like this should give you a head-start on a friendly, usable comparison that automatically loops through every stored data attribute, with subheadings for each one:

<% @live_object.each do |key, value |%>
<% if value.present? || @snapshot.object[key].present? %>
<h4><%= key.humanize %></h4>
<% if @live_object[key].is_a?(Array) %? <%= diff(
ordered(@snapshot.object[key]),
ordered(live[key])
) %>
<% else %> <%= diff(
@snapshot.object[key].to_s,
@live_object[key].to_s
) %>
<% end %>
<% end %>
<% end %>

Let’s walk through it:

  • We can map over each key in the current live object and if it has a value, we’ll add a section to the screen comparing that to the selected snapshot.
  • We display a humanized version of the key as a heading.
  • If the value is an array, we use a special helper function ordered() to process the data before we diff it. In this example, the nested has_many association we included early on in our capture callback will be an array of hashes.
  • If not, simply pass the current snapshot object and the live object’s string representations to the diff() helper function.

We’re using three helpers in helpers/snapshot_helper.rb:

module SnapshotsHelper  def ordered_hash(hash)
hash.sort_by{|key, value| key}.to_h.map do |key, value|
"#{key.humanize}: #{value}"
end.join("\n")
end
def ordered(array)
array.sort_by{ |o| o["id"]}.map do |element|
if element.is_a?(Hash)
ordered_hash(element)
els
e
element
end
end
.join("\n\n")
end
def diff(old, new)
Diffy::Diff.new(old, new, :allow_empty_diff => false).to_s(:html).html_safe
end
end

In reverse order:

  • diff calls the Diffy gem from earlier with some extra options that make sure it prints out as we want it. It accepts two strings.
  • ordered forces an array of nested hashes to appear in a consistent order by sorting it by the id key. This is necessary because otherwise, the order of elements in the array can vary between versions, confusing our diffs.
  • If the array contains hashes, ordered_hash is additionally used to turn the hash into a human-readable format with the keys ordered alphabetically.
Nested “array of hashes” association data could be displayed like this.

This example is scalable and somewhat automatic since it will loop through whatever keys and values exist and attempt to present it all in a human-readable way.

It’s a compromise between doing the fastest, simplest thing: simply running the string representation of the entire object through a diff…

<%= diff(@snapshot.object.to_s, @live_object.to_s) %>

…and the opposite: manually deciding how to treat and display every attribute. The right way for you depends on how competent and experienced your users are.

Improving it further

Once you start automatically capturing snapshots on a model, there’s plenty of things you can do with it, including:

  • Adding a history timeline to your interfaces, summarising the last few times the object was last changed and who by.
  • Adding an approved boolean column to your model which administrators need to approve, and use scopes to revert to the last approved version if the latest version isn’t yet approved.
  • Giving users the ability to restore older versions in one click by reassigning the live attributes to those from the selected snapshot, then saving it.

Further reading

Read more about our tech work. If you’d like to chat about how we might support your organisation, please get in touch.

--

--

Jaye Hackett
FutureGov

Strategic designer & technologist. Why use three short words when one long weird one will do? jayehackett.com