Preview Component
In this post, we will look at the reviewer experience in RailsReviews when commenting on a sandbox offense. Essentially this entails a markdown editor a la GitHub, which provides a preview as you type. Notably, we will make sure that only authorized users can edit such a comment, but the customer, for example can not.
For this, I took some inspiration from the excellent server rendered live previews example by Thoughtbot, but added my own reactive sprinkles to it. We start with a simple tabbed editor that offers writing in the first, and previewing in the second tab. As it stands it's only a textarea that doesn't do any persisting.
Markup Structure
The structure of the markup looks like this:
- we render a collection of
Comments
in an edit view - in the collection partial, we lazy load a Turbo Frame which will request either the edit or the show route based on authorization level determined by Pundit
- if the user is a reviewer, the edit route is loaded, wrapping the comment itself in a form
- in the comment partial, we instantiate a ViewComponent wrapping the previewing functionality, a
PreviewableMarkdownAreaComment
and pass in the comment, the form, and which attribute we are editing
<!-- app/views/reviews/code/edit.html.erb -->
<%= turbo_frame_tag :review_tab_content do %>
<div class="space-y-3">
<%= render partial: "comments/index", collection: @comments,
as: :comment %>
</div>
<% end %>
<!-- app/views/comments/_index.html.erb -->
<%= turbo_frame_tag dom_id(comment), src: policy(comment).edit? ?
edit_comment_path(comment) : comment_path(comment), class: "block" %>
<!-- app/views/comments/edit+code.html.erb -->
<%= turbo_frame_tag dom_id(@comment), class: "block" do %>
<%= form_with model: @comment do |form| %>
<%= hidden_field_tag :variant, "code" %>
<%= render "reviews/code/comment", comment: @comment, form: form %>
<% end %>
<% end %>
<!-- app/views/comments/_comment.html.erb -->
<!-- other markup omitted -->
<%= render(PreviewableMarkdownAreaComponent.new(object: comment,
form: form, attribute: :body) %>
Essentially, we are looking at this white box featuring a write and preview tab:
The tabbing functionality is provided by a stimulus controller, nothing fancy here. In the preview tab, we are using kramdown to render the preview from markdown.
Now, let's start building out that automatic preview functionality. We'll use the textarea's input event to save intermittently.
<!-- app/components/previewable_markdown_component.html.erb -->
<div data-controller="tabs" data-tabs-active-tab="..." data-tabs-index="<%= 1 if preview %>">
<% if form.respond_to?(:text_area) %>
<div class="border-b border-gray-200 not-prose">
<div class="..." role="tablist">
<a href="#" class="..." data-tabs-target="tab" data-action="click->tabs#change">Write</a>
<a href="#" class="..." data-tabs-target="tab" data-action="click->tabs#change">Preview</a>
</div>
</div>
<% end %>
<div class="mt-2">
<!-- write tab -->
<% if form.respond_to?(:text_area) %>
<div class="..." role="tabpanel" tabindex="0" data-tabs-target="panel">
<label for="comment" class="sr-only">Comment</label>
<div>
<%= form.text_area attribute, class: "...", rows: 7, autofocus: true, data: {action: "debounced:input->form#requestPreview"} %>
</div>
</div>
<% end %>
<!-- preview tab -->
<div class="... <%= "hidden" if form.respond_to?(:text_area) %>" role="tabpanel" tabindex="0" data-tabs-target="panel">
<div class="...">
<div class="prose ...">
<%= Kramdown::Document.new(body, input: "GFM", syntax_highlighter: "rouge").to_html.html_safe %>
</div>
</div>
</div>
</div>
</div>
First, we have a stimulus form_controller
with a requestPreview
method that essentially submits the form data via a request.js PATCH call. I deviated from the Thoughtbot approach here, which includes an invisible Preview button, to streamline the Rails controller action a bit.
We'll connect this to our form element here, then add a data-action
to the textarea, requesting the preview. Note that we are using Nate Hopkins's excellent debounced library to debounce this event with a single event prefix.
Passing in the Form
Pay attention to what's happening in the Network tab: the PATCH request is dispatched, and returns a 204/No Content response. In the request header we can assert that indeed we expect a TurboStream response by inspecting the Accept
header. The payload includes the changed comment body, so it looks like our client side logic is correct. If we switch to the "Preview" tab, the content hasn't changed though. We will look at how to enable automatic previews in a second.
Here's an interesting little tidbit: In our component, we check if our form actually behaves like a Rails form. If it doesn't, the interactive parts will not render, leaving just the preview. Thus we can accommodate for the unauthorized case very cleanly. Remember, back in our lazy loaded Turbo Frame, we requested the edit route, passing in the form like this.
In the show route though, we simply pass in nil
. Of course we still need to use Pundit to authorize our comment on the controller level, but this makes for a very simple method to choose a code path without using a conditional in the main view.
<!-- write tab -->
<% if form.respond_to?(:text_area) %>
<div class="..." role="tabpanel" tabindex="0" data-tabs-target="panel">
<label for="comment" class="sr-only">Comment</label>
<div>
<%= form.text_area attribute, class: "...", rows: 7, autofocus: true, data: {action: "debounced:input->form#requestPreview"} %>
</div>
</div>
<% end %>
If we open the browser in an anonymous window, we will see that indeed a normal user cannot edit the comment.
Now what about our actual functionality? Let's remove "foo" here. Some Fetches indeed seem to occur here, and if we reload the page, our edit indeed was persisted. Hooray!
Live Previews
That still doesn't count as a "live preview", though. And here is where CableReady::Updatable
comes in handy, to make this work without having to use Turbo Streams. For starters, let's wrap the preview tab in an updates_for helper. That's essentially setting up an ActionCable subscription for precisely this comment, and nothing else. Everything that's enclosed in this helper will be automatically morphed when the comment is updated. We can further scope this by providing only:
attribute to ignore all other updates to the model.
<div class="... <%= "hidden" if form.respond_to?(:text_area) %>" role="tabpanel" tabindex="0" data-tabs-target="panel">
+ <%= updates_for object, only: attribute do %>
<div class="...">
<div class="prose ...">
<%= Kramdown::Document.new(body, input: "GFM", syntax_highlighter: "rouge").to_html.html_safe %>
</div>
</div>
+ <% end %>
</div>
Now we need to engage this functionality in our model. Here's a short aside: We are actually dealing with Single Table Inheritance here, where Comment
is a subclass of ContentElemen
t. So in this model we call the class method enable_updates, and specify which CRUD action(s) to scope it to - in our case update. Note that it is necessary to include CableReady::Updatable
, which I have done in ApplicationRecord
.
# app/models/content_element.rb
class ContentElement < ApplicationRecord
# ...
enable_updates on: update
# ...
end
And that's it! That's everything necessary. The CommentsController
's update
action can actually return a 204/No Content response, since everything inside the updates_for
tag will be updated automatically.