https://nts.strzibny.name/rails-stimulus-live-preview/
/home/nts
Live previews with Rails and Stimulus 2
23 Mar 2021
Who wouldn't want a live preview for writing their great content? If
you happen to be running Rails with Hotwire, it's surprisingly easy
with a small Stimulus controller.
Based on the feedback to this post (primarily thanks to edwinvlieg
from HN), I decided to include a solution using Turbo as well. So the
first example uses a regular AJAX with Rails UJS and then we change
it to a Turbo Frame.
Here's the simple demo:
demo
Rails UJS + Stimulus 2
The core idea of our preview is that it's rendered entirely on the
backend. This is useful since we can reuse the logic both for
presentation as well as actual changes. Whatever happens with the
content can come from a single Ruby method. We'll start with a small
controller and a preview method:
class PreviewController < ApplicationController
def preview
preview = "#{request.raw_post}
<% if tweet.errors.any? %>
<%= pluralize(tweet.errors.count, "error") %> prohibited this tweet from being saved:
<% tweet.errors.each do |error| %>
- <%= error.full_message %>
<% end %>
<% end %>
<%= form.label :body %>
<%= form.text_area :body, placeholder: "Type a tweet", data: { "composer-target": "tweet", "action": "input->composer#preview" } %>
<%= form.submit %>
<% end %>
As you can see, I adjusted the default template with a little bit
Stimulus 2 data attributes. I wrapped the whole form in a div with
data-controller set to composer, which will be the Stimulus
controller's name. I added two data attributes to the text area:
data-composer-target identifying the text area and data-action
specifying composer#preview action on any input. Then at the bottom,
we just have a with data-composer-target set to output. This
target will be used for inserting the rendered HTML from the backend.
For the view to work, we'll need to write a composer_controller.js
that will react to input changes taking data from the text area and
present them to the output div.
But before we do, we have to make sure we have both Rails UJS and
Stimulus set up as we'll use UJS Rails.ajax call. The application.js
in app/javascript/packs needs to look similar to this:
import Rails from "@rails/ujs"
import "@hotwired/turbo-rails"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
Rails.start()
ActiveStorage.start()
// Stimulus.js
import { Application } from "stimulus"
import { definitionsFromContext } from "stimulus/webpack-helpers"
const application = Application.start()
const context = require.context("./controllers", true, /\.js$/)
application.load(definitionsFromContext(context))
The final piece is the controller itself:
import { Controller } from "stimulus"
import Rails from "@rails/ujs"
export default class extends Controller {
static targets = [ "tweet", "output" ]
connect() {
this.preview()
}
preview() {
var content = this.tweetTarget.value;
var preview = this.outputTarget;
Rails.ajax({
type: "post",
url: "/preview",
contentType: "text/plain",
data: content,
success: function(data) {
preview.innerHTML = data
}
})
}
}
In the beginning, we define the tweet and output targets. We call
preview on connect() to make sure the initial preview will be
rendered and then define the primary preview method.
The preview() method then takes the content from the tweet target and
sends it using the Rails.ajax call as text/plain. Once we receive a
preview response back, we directly inject it into the rest of the
page by setting the innerHTML attribute on the output target.
If you didn't modify the initial method, you'd get the whole text
previewed in bold.
Turbo Frame + Stimulus 2
The basic idea of a Turbo Frame is that is will automatically load a
remote HTML page or partial from a given URL:
<%= form_with(model: tweet) do |form| %>
<% if tweet.errors.any? %>
<%= pluralize(tweet.errors.count, "error") %> prohibited this tweet from being saved:
<% tweet.errors.each do |error| %>
- <%= error.full_message %>
<% end %>
<% end %>
<%= form.label :body %>
<%= form.text_area :body, placeholder: "Type a tweet", data: { "composer-target": "tweet", "action": "input->composer#preview" } %>
<%= form.submit %>
<% end %>
The form is the same except two changes. First, we change the regular
div for the output to be the Turbo Frame:
And we include the preview URL we'll need as part of the Stimulus
value attribute:
The idea is to take this URL (it has to be an absolute URL), append
the text from the text area, and update the url attribute of the
Turbo Frame. Turbo Frame then reloads the preview automatically.
On the backend, the preview method renders the full preview from a
body param:
...
def preview
@preview = "#{params[:body]}"
end
...
But this time we have to include the frame as well, so I will create
a proper preview.html.erb template:
<%= raw @preview %>
The Stimulus controller then has to take the value from the textarea
and append it to the preview URL:
import { Controller } from "stimulus"
import Rails from "@rails/ujs"
export default class extends Controller {
static targets = [ "tweet", "output" ]
static values = { previewUrl: String }
connect() {
this.preview()
}
preview() {
let url = new URL(this.previewUrlValue);
url.searchParams.append('body', this.tweetTarget.value);
this.outputTarget.src = url.toString();
}
}
Turbo Frame does the the AJAX for us and all is well.
And that's it. Two different approaches of how to start doing
previews with Rails and Hotwire. There are even more ways how to
achieve it, but I deliberately avoided depending on passing a model
around.
rails stimulus
Any comments? Write me a direct message at @strzibnyj.
[twitter]
- SOON
[cover_160]
I am writing an introductory book on web application deployment.
Networking, processes, systemd, backups, and all your usual suspects.
Open -