Do it Live: Live, Cross-Tab Edit Previewing in Rails Using HTML5 Local Storage
Lawson Kurtz, Former Senior Developer
Article Category:
Posted on
When developing applications that allow users to manage site content, the ability to preview edits before making them live to thousands (if not millions) of visitors is paramount. Despite the importance of the task, implementing content edit previewing is still a non-trivial task. Most applications end up with overly-complicated (read: expensive) content versioning systems even if users only really need the ability to take a quick peek at the page with their edits.
We recently developed a simple, cost-effective method to allow a client to preview their changes in real-time using HTML5 local storage.
The Concept
- Every bit of content on the site that is editable receives a unique preview key (identical in concept to a cache key).
- Each key is in turn associated with both the form input that defines its content's value within the CMS, and the area where that content is displayed on the public page.
- When changes are detected on the form inputs, new values for the content are written to local storage, indexed by the unique preview key.
- On the public page, when preview criteria are met (in our case, when an administrator is logged in and a preview parameter is set in the request), each content area listens to changes to the value at its unique preview key in localstorage. When that value changes, it updates accordingly.
The Advantages
The advantage of this method of previewing is its simplicity. Since it doesn’t affect the way you model or store data, implementation and maintenance are quick and easy. And from the user’s perspective, it’s intuitive and uncomplicated to use. Just hit ‘Preview’ and edit away.
The Drawbacks
While great for certain, limited applications, this method of previewing has a number of constraints. The most obvious is that since it relies on local storage, there is no way to share the preview of edits with others. The previews are also non-persistent, so it does not provide a method of saving drafts, or keeping track of changes over time. It's also limited in its capabilities to preview certain backend-intensive interactions such as image processing or associations.
Browser compatibility is also an important consideration. Our application of this method of previewing has been with a small number of administrators who use modern browsers. If support for older browsers is a priority, this solution probably isn't for you.
The Sample Rails Implementation
We begin by defining a module that we use to create consistent preview keys for resources throughout the application.
# lib/preview.rb
module Preview
private
# Builds a localStorage key unique to a given object/attribute pair
def preview_key_for(resource, attr)
resource.class.to_s.underscore.tap do |key|
key.concat "/#{resource.id}" if resource.respond_to?(:id)
key.concat "/#{attr}"
end
end
def preview_key_data_attr
'data-preview-key'
end
def preview_markdown_data_attr
'data-preview-markdown'
end
end
Then we add some helpers for our preview functionality:
# app/helpers/application_helper.rb
module ApplicationHelper
include Preview
...
def preview_link_for(path_object)
path = preview_path(path_object)
link_to "Preview", path, :target => "_blank" if path
end
def preview_path(path_object)
if path_object.kind_of?(ActiveRecord::Base)
polymorphic_path([path_object], :preview => true)
elsif(path_object.is_a?(Symbol))
send(path_object, :preview => true)
end
end
# Wraps CMS-defined content in divs with a preview-specific data attributes
def previewable(resource, attr, options = {}, &default_content)
if preview?
markdown = options.fetch(:markdown, false)
data_attrs = { preview_key_data_attr => preview_key_for(resource, attr), preview_markdown_data_attr => markdown }
content = default_content.call || ""
content_tag(:div, content.html_safe, data_attrs)
else
default_content.call
end
end
end
Next we define a custom FormBuilder which will assign form inputs their appropriate preview keys.
# lib/previewable_form_builder.rb
class PreviewableFormBuilder < ActionView::Helpers::FormBuilder
include Preview
def text_field(method, options = {})
options.merge!(preview_key_hash(method))
super(method, options)
end
def text_area(method, options = {})
options.merge!(preview_key_hash(method))
options.merge!(preview_markdown_hash(options.delete(:markdown)))
super(method, options)
end
private
def preview_markdown_hash(boolean)
{ preview_markdown_data_attr => boolean }
end
def preview_key_hash(method)
{ preview_key_data_attr => preview_key_for(@object, method) }
end
end
In your admin interface edit view, you can use the custom form builder and provide a link to the preview like so:
# app/views/admin/your_resources/edit.html.erb
<% content_for :taskbar_buttons do %>
<%= preview_link_for(resource) %>
Save
<% end -%>
<%= form_for [:admin, resource], :builder => PreviewableFormBuilder, :html => {:class => 'form-standard form-main'} do |f| %>
<%= render "form", :f => f %>
<div class="form-group no-js mobile">
<%= f.submit "Save", :class => "button" %>
</div>
<% end %>
Now in your public resource view, you can define areas that contain previewable content.
# app/views/your_resources/show.html.erb
<h1>
<%= previewable(page_resource, :name){ page_resource.name } %>
</h1>
<div class="cms-html">
<%== previewable(page_resource, :body, :markdown => true){ Kramdown::Document.new(page_resource.body).to_html } %>
</div>
# Results in output like this during preview:
# <div data-preview-key="contact_us_page/heading" data-preview-markdown="true"><h1>test</h1></div>
Next, we'll declare the criteria for enabling the preview functionality on a page.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
helper_method :preview?
...
# Determines the criteria for previewing changes in public views
def preview?
logged_in? && params[:preview]
end
end
Then we bind everything together with our JavaScript.
# app/assets/javascripts/admin.js
// Within the CMS, each keystroke triggers a save of a previewable input's
// value within localStorage at the input's unique key
var saveForPreview = function(e) {
var $target = $(e.target);
if ($target.data('previewKey')) {
localStorage.setItem($target.data('previewKey'), $target.val());
}
}
$(window).on('keyup', saveForPreview);
# app/views/layouts/application.html.erb
<%
# On the public side, if preview? criteria are met, JS lies in wait for a `storage` event. Upon a `storage` event, the appropriate previewable wrapper div will update its value with the value now in localStorage. In this particular case, we also accommodate markdown processing as necessary.
%>
...
<% if preview? %>
<%= javascript_include_tag "markdown" %>
<script>
var updatePreview = function(e) {
// storage object
var s = e.originalEvent;
var $el = $('[data-preview-key="' + s.key + '"]');
var isMarkdown = $el.data('previewMarkdown');
// check format and update element
if (isMarkdown) {
$el.html(markdown.toHTML(s.newValue));
} else {
$el.html(s.newValue);
}
}
$(window).on('storage', updatePreview);
</script>
<% end %>
...
Voilà!
The Summary
HTML5 local storage can provide a convenient, uncomplicated, and inexpensive way to preview content edits in your web applications. How do you manage edit previews wihin your applications?