Blog article versioning in Ruby on Rails - a post mortem
March 28, 2015 in Ruby on RailsOne 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
Liked the post? Treat me to a coffee