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 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_save

    def 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 :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 is 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() 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)
    else
    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

Get in touch

We’re always happy to answer any questions you have about FutureGov and discuss how we can work together.

Contact us