HTML Render

The simplest of the Gems I have written is this small tool that does one powerful thing.

https://github.com/jasonfb/html-render

For some reason or another, you want to build an app that fetches content from your API using JSON. You want to do this for entirely good reasons.

You want to get a full layout or a partial and render it as-if Rails would be rendering it if it were displaying the view, but instead stuff that response back into a JSON object.

How to Render HTML inside a JSON Response in Rails

For this quick setup guide, we’re going to create a simple table of jobs. The jobs will process some operation (not implemented fully in this example), that will take about 3-5 seconds to complete.

We will have a backend controller that will display the list of jobs. We will have some front-end code that will allow you to create a new job. The front-end code will poll the backend controller every 1 second. That means it will ask the backend if that job is completed or not.

The backend will respond to this poll request with JSON containing two nodes: status and result_html. Status is either ‘pending’ or ‘finished’. Result_html will be empty until the job is finished, and then it will be a block of HTML code that represents the table line of the job as it is displayed.

I’m using this as an example pattern because it is a good example of an old-fashioned poller, and good mixing of data-like knowledge (JSON) and an HTML-render that reuses the job line. That part is important: The job line view is rendered by Rails two different ways: it is rendered in the normal index view when we load the jobs list and it is also used by the action that responds to the poller. When we use it from that action, we’ll use this special tool to get the partial out of Rails and render it to a string

render_html_content(partial: "request/line", layout: false)

The ultimate goal is to have a result that looks like this:

Let’s get started. You can find the complete repository for this example app on Github. Remember, this is the example app walk-through. For just the docs for htmlrender, see this link.

Quick Start

First, I’m going to assume you’re on the most recent vertain of Rails, which is

There is some additional setup to back-port jQuery into this version of Rails because jQuery is no longer included with Rails.

rails new MyPollingApp

cd into your new app, then go to the Gemfile and add

gem 'html_render'

Then run bundle install

Add Bootstrap

Add to the Gemfile again

gem 'bootstrap'
gem 'font-awesome-rails'

Go to app/assets/stylesheets/application.css

You have two choices: (1) keeping the existing file, rename only the file extension from .css to .scss, and then changing the entire file’s contents (2) Delete the existing file completely, and then creating a new file in its place application.scss

Application.scss starts out with only one line:

@import 'bootstrap';
@import 'font-awesome';

I recommend strategy #2. Do not, under any circumstances, keep the old content from application.css. It looks like this:

*= require_tree .
*= require_self

Warning: Do not keep this.

Add jQuery

Add jquery to your package.json via yarn

yarn add jquery

Go to config/webpack/environment.js and add this code in between the existing two lines

const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

The complete environment.js file looks like so (the part you are adding is shown in red.)

const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment

Add require("jquery") to your application.js file so it looks like

require("@rails/ujs").start()
require("turbolinks").start()
require("@rails/activestorage").start()
require("channels")
require("jquery")

Setup Requests Model

bundle exec rails generate model Request random_seconds:integer finished_at:datetime

The generator will create files here

      invoke  active_record
      create    db/migrate/20200804164242_create_requests.rb
      create    app/models/request.rb
      invoke    test_unit
      create      test/models/request_test.rb
      create      test/fixtures/requests.yml

If we take a look at the migration file, we can see the requests are created like so:

class CreateRequests < ActiveRecord::Migration[6.0]
  def change
    create_table :requests do |t|
      t.integer :random_seconds
      t.datetime :finished_at        
      t.timestamps
    end
  end
end

Notice that the requests are created with the t.timestamps, which will make tell Rails to make created_at and updated_at fields as well. (We’ll use the created_at to determine if the number of random seconds have passed. In this way, we’ll create requests that will a varying number of seconds to complete, similar to how your jobs might be if they were real operations in your app.)

run bundle exec rake db:migrate

== 20200804164242 CreateRequests: migrating ===================================
-- create_table(:requests)
   -> 0.0020s
== 20200804164242 CreateRequests: migrated (0.0020s) ==========================

Make a Requests controller using

bundle exec rails generate controller Requests

Then, add to you routes.rb file

Our requests_controller.rb will be empty. Next, add these two methods to it.

class RequestsController < ApplicationController

def index

end

def show

end

end

Start up your rails server (bundle exec rails server)

and go to http://127.0.0.1:3000/requests/

Since there is no file for index, we get this error.

Next, let’s make a file at app/views/requests/index.erb

<h2>All requests</h2>

<table class="table" data-role="requests-list">
<thead>
<tr>
<th> Request id </th>
<th> Created at </th>
<th> Finished At </th>
</tr>
</thead>
<tbody>
<% @requests.all.each do |request| %>
<tr >
<td><%= request.id %> </td>
<td><%= request.created_at %> </td>
<td><%= request.finished_at %> </td>
</tr>
<% end %>
</tbody>
</table>

Go back to requests_controller and load the request in the index method using @requests = Request.all

Your new requests_controller.rb looks like this.

class RequestsController < ApplicationController
  def index
    @requests = Request.all
  end

  def show

  end
end

When viewed in your browser yoru empty requests list looks like this:

Now let’let’s add a new button. Go back to app/views/requests/index.erb and add this to below the “All Requests” heading

<%=  form_with model: Request.new,
                  url: new_request_path,
                  method: "post" do  |f|
%>

    <%= f.submit "New",  class: 'btn btn-primary float-right' %>
<% end %>

Your page will now have a New button on it

Go back to the requests_controller.rb and add code that responds via JS on to a new create action:

class RequestsController < ApplicationController
  def index
    @requests = Request.all
  end

  def show

  end

  def create
    respond_to do |format|
      format.js 
    end
  end
end

Create a file at view/requests/create.js.erb

$("#requests-table").append("<tr><td>Processing...<i class=\"fa fa-refresh spin\" /></td></tr>")

Now when you open your app, you should see:

Let’s create a new request object:

Add to your create action in requets_controller.rb

def create
@request = Request.create(random_seconds: (4 + rand(5))) #random integer between 4 and 9
respond_to do |format|
format.js
end
end

Adding the Poller

Go back to create.js.erb and add the poller. Note that all of the new code is added in boldface, and also note that I added data-id attribute to the table row element created by the jquery .append method. This is important becuase this is how we find and replace the row inside of the poller.

$("#requests-table").append("<tr data-id=\"<%= @request.id %>\"><td>Processing...<i class=\"fa fa-refresh spin\" /></td></tr>")

checkForRefersh = function() {
  console.log("polling for update...")
  $.ajax({
    dataType: "json",
    url: '<%= request_path(@request) %>',
    success: function(data) {
      if (data.status === "pending") {
        setTimeout(function () {
          checkForRefersh()
        }, 1000)

      }
      if (data.status === "finished") {
        $("i.fa-refresh").removeClass('spin')
        $("tr[data-id='<%= @request.id %>']").html(data.row_html)
      }
    }
  })
}

setTimeout(function() {
  checkForRefersh()
},2000)

Finally, to tie it all together (almost), let’s implement the show action:

def show
@request = Request.find(params[:id])
if Time.current - @request.created_at > @request.random_seconds
@request.finish!
end

respond_to do |format|
format.json {render json: {
status: @request.finished_at ? "finished" : "pending",
row_html: "what is this?"
}
}
end
end

You will need to implement a finish! method on your request.rb model

class Request < ApplicationRecord
  def finish!
    self.update(finish: Time.current)
    self.save!
  end
end

Finally, you should have an almost-woring application that looks like this:

There’s a number of bad things about this code: (1) it has a non-idempodent show action where looking at the object could change its state, (2) the controller has some fake logic to check the number of seconds since the time the request was created. In a real app, don’t do this. This is to demonstrate how, theoretically, a job might process in the background.

HTML Render

Thta was a huge setup for a simple payoff:

We’re going to abstract the line into a partial. Go back to app/views/requests/index.erb and change it to

<h2>All requests</h2>
<%=  form_with model: Request.new,
                  url: requests_path,
                  remote: true,
                  method: "post" do  |f|
%>

    <%= f.submit "New",  class: 'btn btn-primary float-right' %>
<% end %>

<table class="table" data-role="requests-list" id="requests-table">
  <thead>
    <tr>
      <th> Request id </th>
      <th> Created at </th>
      <th> Finished At </th>
    </tr>
  </thead>
  <tbody>
    <% @requests.all.each do |request| %>
        <tr data-id="<%= request.id %>">
          <%= render partial: "line", locals: {request: request} %>
        </tr>
    <% end %>
  </tbody>
</table>

(new code shown in boldface.)

Now create a file at app/views/requests/_line.erb that contains the content you just abstracted out of the index view:

<td><%= request.id %> </td>
<td><%= request.created_at %> </td>
<td><%= request.finished_at %> </td>

Go back to your Rails server, and confirm that there’s no change to your functionality.

The final step is to change the “what is this?” string in the show action

Add include HtmlRender to your ApplicationController

class ApplicationController < ActionController::Base
  include HtmlRender
end

Modify your requests_controller.rb show action

def show
  @request = Request.find(params[:id])
  if Time.current - @request.created_at > @request.random_seconds
    @request.finish!
  end

  respond_to do |format|
    format.json {render json: {
        status: @request.finished_at ?   "finished" : "pending",
        row_html: render_html_content(partial: "requests/line", layout: false, locals: {request: @request})
      }
    }
  end
end

Now your application is done: The Rails-side renders the partial from _line.erb back to the frontend inside of a JSON node. Specifically, a node that we arbitrarily called row_html. (I called it “html” because it it is literally a block of HTML).

Finally, to put the cherry on top, go back to application.scss and add code to made the spinner spin.

@import 'bootstrap';
@import 'font-awesome';

i.fa-refresh.spin {
  -webkit-animation:spin 2s cubic-bezier(0.1, 0.7, 1.0, 0.01) infinite;
  -moz-animation: spin 2s cubic-bezier(0.1, 0.7, 1.0, 0.01) infinite;
  animation: spin 2s cubic-bezier(0.1, 0.7, 1.0, 0.01) infinite;
}

@-moz-keyframes spin {
  from { -moz-transform: rotate(0deg); }
  to { -moz-transform: rotate(360deg); }
}
@-webkit-keyframes spin {
  from { -webkit-transform: rotate(0deg); }
  to { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
  from {transform:rotate(0deg);}
  to {transform:rotate(360deg);}
}

You can find the complete repository for this exmaple app on Github.