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.