Client side user generated template previews with Rails and Stimulus.js

01 Nov 2022

I’m going to use Ruby on Rails and Stimulus.js in this post. Let’s assume we already have a Rails app, so we’ll just focus on generating models in our existing app. In this tutorial we’ll focus on doing everything 100% client-side, we won’t have any liquid placeholders or anything like that that we need to hydrate on the server.

Time to create the basics

Since I don’t want to focus too much on the basic Rails stuff in this post. I’m going to use the scaffold generator of Rails to get started with a simple model, views and a controller for my resource.

Let’s call the resource for Template and create it like so

rails g scaffold Template title body:text

This will give us the views and backing controller that we need and a model named Template with a title of type string and a body of type text.

We need to run the migrations

rails db:migrate

If we now go visit http://localhost:3000/templates after having started our server with rails s we should see the “Templates” index page.

We can click the “New template” link and create a new template by filling in the title and body fields and clicking on the “Create template button”. That’s fun and all, and we could definitely call it a a day here. But let’s try and make some preview functionality :)

Time for the preview functionality

My thought here is, that we could have a user of our app to create a custom template for some email functionality or a custom view we have. They would write the template (html, css and everything) in the body field and could click a button that would preview the content.

Since we give them the ability to write their own inline styles (yuck, I know - but that’s how it’s done for email templates which is what I implemented in my app :)) we need to make sure that these styles don’t break functionality of MY app in which they’re writing these templates. In order to do so, we’re going to render their template inside an iframe when they preview it.

So we want an editable field, and then a button they can press to see the rendered preview.

Stimulus.js to the rescue

Since we’re going to write a tiny bit of JavaScript, let’s reach for Stimulus.js which is the preferred way to do it in a standard Rails app.

rails g stimulus template 

This creates a new stimulus controller in app/javascript/controllers/template.js.

Go ahead and open the template form partial in app/views/templates/_form.html.erb. Let’s wrap the whole form in a div and give it a data-controller="template" controller data attribute.

<div data-controller="template">
  <%= form_with(model: template) do |form| %>
    ...
  <% end %>
</div>

This is what will load the stimulus controller we just created when the DOM is loaded. We can quickly test this by adding a console.log to the connect function of our app/javascript/controllers/template_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="template"
export default class extends Controller {
  connect() {
    console.log("template")
  }
}

If you refresh the edit page of an existing template of yours or go to http://localhost:3000/templates/new you should see “template” in the developer console in your browser. This means that things are hooked up the way we had hoped and we can continue with the fun stuff.

Let’s preview stuff

We have our textarea and we want to take the content of that and render it in an iframe. First we need to be able to identify the textarea from within the stimulus controller. You could just do a document.getElementById("template_body") - but that adds a weird dependency on your documents markup, which we’re not interested in. So instead we’re going to use the targets functionality of stimulus.js.

In your stimulus controller inside the class definition add the following:

static targets = ["field"]

Then in your view on your body text_area in _form.html.erb add a new data-attribute like so:

<%= form.text_area :body, data: { template_target: "field" } %>

That makes it so we can reference the text area from inside the stimulus controller by calling this.fieldTarget which will give us a reference to it.

In order to make sure this works the way we hope, let’s add a function to the stimulus controller called preview and let’s hook that up to a button in our view.

Inside the stimulus controller we add the following:

preview(e) {
  e.preventDefault()
  console.log("preview", this.fieldTarget)
}

and in the view app/views/templates/_form.html.erb we add the button somewhere inside the div that has the data-controller attribute:

<button data-action="click->template#preview">Preview</button>

There’s a few things going on here. So let’s walk through those in case you’re not familiar with Stimulus, if you are - skip it. Notice the data-action attribute on our button that tells stimulus that on click we want to run the preview function in the template controller.

In the preview function in our controller we take in the event e and call preventDefault() on it, to prevent the button from submitting our form and then we print out a console.log with the text area field target. Just to confirm that this works.

Now that we have the text area reference and the function hooked up, we can add the iframe to the page:

<div>
  <iframe data-template-target="iframe"></iframe>
</div>

and inside the stimulus controller we’ll add that to our targets array:

static targets = ["field", "iframe"]

Then in our preview function we can take the content of the text area and insert that in to the iframe like so:

preview(e) {
  e.preventDefault()
  this.iframeTarget.contentWindow.document.body.innerHTML = this.fieldTarget.value;
}

and that should give you simple preview functionality. If you write something like:

<style>
h1 { color: red; }
</style>

<h1>Hello there</h1>

some text

You should see a red h1-heading on the screen when you press the preview button. You could get rid of the preview button all together and just have it update when someone types in the field. All you’d need to do is move the data-action attribute to the text area and change click to input, like so:

<%= form.text_area :body, data: { template_target: "field", action: "input->template#preview" } %>

I decided against this, as the experience is a little strange when you input HTML tags. Content might jump around or be hidden as you type HTML tags, which is to fully be expected - so I thought the preview button would be a better experience.

Let’s add some tabs

If you wanted some tab-like functionality where you could switch between seeing the text area and seeing the preview, we could add that relatively easily by adding a new button next to the Preview-button:

<button data-action="click->template#edit">Edit</button>
<button data-action="click->template#preview">Preview</button>

We would then add this new edit function to our stimulus controller:

edit(e) {
  e.preventDefault()
  this.fieldTarget.style.display = "block";
  this.iframeTarget.style.display = "none";
}

Same deal here where we call e.preventDefault() to prevent clicking the button from submitting the form. We then set the display style value to block for the textarea field and none for the iframe when clicked.

We want to do the opposite in the preview function, where we hide the field and show the iframe when the Preview button is clicked:

preview(e) {
  e.preventDefault()
  this.fieldTarget.style.display = "none";
  this.iframeTarget.style.display = "block";
  this.iframeTarget.contentWindow.document.body.innerHTML = this.fieldTarget.value;
}

and this is what the final result looks like (without any styling of course)

As you can probably see, we can improve on this quite a bit - mostly with styling. But it’s a simple and quick way to create previews for templates on the client.

🔥 Notes of caution!

If you were going to use this in production. I would disallow the use of ERB tags in this and strictly do liquid placeholders - where YOU control what type of content/code a user can execute. If you allowed ERB tags and you found their template and used it in an actual ActionMailer, they would be able to read files from your file system and run destructive code ..which is NOT what you want :)