Do it Live: Live, Cross-Tab Edit Previewing in Rails Using HTML5 Local Storage

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?

Lawson Kurtz

,
Posted in Article Category: #Code
on