Blog article versioning in Ruby on Rails - a post mortem

March 28, 2015 Ruby on Rails blogging paper_trail PaperTrail vestal_versions

One of the major features I wanted on my blog is an article editor that can update articles without publishing the changes straight away. (For example, Wordpress has this feature built in.)

I wanted an approach akin to a git-driven app development & deployment flow: make a draft version, proof-read, then publish changes; then make more changes, proof-read them privately, then publish again.

Implementation

I decided to use the paper_trail gem to make my article model versioned. I used the vestal_versions gem before, but it isn’t well maintained anymore.

Every page (I call articles Pages) has a series of versions. It also has a published_at attribute that implicitly points to the published version (that is, the version that is publically visible). If published_at is null, the page is not visible from the public side.

class Page
  # Set up paper_trail versioning
  has_paper_trail({
    if: Proc.new {|page| 
      page.versions.none? || (page.versions.last.created_at<1.minute.ago)},
    skip: [:tag_list]})

  # Define a "publish" pseudo-attribute that is set by submitting the 
  # article editor with "Publish" instead of "Save"
  attr_accessor :publish
  before_save :set_published_at, if: :publish
  def set_published_at
    self.updated_at = Time.zone.now
    self.created_at ||= self.updated_at
    self.published_at = self.updated_at
  end

  scope :published, -> { where(pages.published_at IS NOT NULL) }

  def published?
    self.published_at==self.updated_at
  end

  def published_version
    @published_version ||= version_at(published_at)
  end

  # Methods to determine if the public representation of the modified
  # version of the page is any different from the most recent one
  # (that is, it could be published)
  PUBLISHABLE_FIELDS = %w(title html slug status show_in_blog format locale translated_page_id excerpt show_ads)

  def unpublished_fields
    return PUBLISHABLE_FIELDS unless published_at
    return [] if published?
    return PUBLISHABLE_FIELDS.select do |field_name|
      published_version.send(field_name) != self.send(field_name)
    end
  end

  def needs_publishing?
    unpublished_fields.any?
  end
end

Viewing unpublished versions

I added the ability to see unpublished versions of a page in the public frontend to proofread drafts. Or, more accurately, I saw all pages, while everyone else only saw the ‘visible and published’ set, and got a 404 error for unpublished pages.

Visitors would see the published version, and not the latest one. In fact, the only case when the draft version was visible is if I set the current GET param.

class PagesController < ApplicationController
  def show
    if is_owner?
      @page = Page.find_by_locale_and_slug!(params[:locale], params[:id])
    else
      @page = Page.visible.published.find_by_locale_and_slug!(params[:locale], params[:id])
    end

    unless is_owner? && params[:current]
       @page = @page.version_at(@page.published_at)
    else
      redirect_to page_path(@page) unless @page.needs_publishing?
    end
  end
end

I also used the diffy gem to see changes in my articles before publishing.

Associated objects

Multi-table versioning is a pain. So associated objects are not versioned. In the case of blog articles, the only important association is tags. So, tags were never versioned and “published” straight away instead.

Article slugs

Pages are referenced by slug (and locale), and the finder method only looks at the latest version of the page. I could make slugs also “directly published” like tags, but instead I made it so you can only change the slug if the page is simultaneously published.

Post-mortem conclusions

The major inconvenience with such article versioning is that the published version of a page isn’t in the model object, but hidden inside the version stack. This adds complexity and introduces bugs. For example, as you see, reverting the page to the published version was done in the controller. But the page object is also used in other places, for example, the page list page (oh, and calling the model “Page” wasn’t a good idea, either), where every page object has to be reverted to the published version to show its title.

If I had to design another system that maintains a published version and a private draft version, I’d make the latter into its own object, or at least wrap an abstraction on top of ActiveRecord objects.

Or, not have the concept of “drafts after publishing” at all, as it is not only architecturally, but cognitively complex.

Bonus - autosave editor and warn of unsaved changes on close

My initial backend editor was a plain old Rails form with little conveniences. It wasn’t much more than an “API frontend” which you pasted text into to upload an article to the blog. Eventually I developed a rather convenient online editor backed by the ACE code editor.

The major problem with an online editor was that you lose all changes when you close or reload the page, and it’s not as convenient to save an online editor often as it is a regular text editor. This is why I’ve added autosave for the editor, and a warning on page close - more or less like Google Docs work.

Here’s the full source of autosave + warn-on-close in CoffeeScript on top of jQuery.

getAceText = ->
  $("#editor_input").val window.pageEditor.getValue()

getFormData = ->
  getAceText()
  $(".edit_page").serialize()

lastSavedData = getFormData()

autosave = ->
  data = getFormData()
  if lastSavedData != data
    lastSavedData = data
    $.ajax
      url: $(".edit_page").attr("action") + ".js"
      method: "put"
      data: data
      dataType: "json"
      success: (data) ->
        if data.success
          $("#save_status").text data.status

  setTimeout autosave, 5000

setTimeout autosave, 5000

warnOfUnsavedChanges = (e) ->
  if lastSavedData != getFormData()
    message = 'There are unsaved changes! Close form and lose them?'
    e?.returnValue = message
    return message
  else
    return null
window.onbeforeunload = warnOfUnsavedChanges

# Don't warn when submitting the form
$('form.page').submit ->
  window.onbeforeunload = false
  return true

Buy Me a Coffee at ko-fi.com