How to create a progress bar using Ruby on Rails, AJAX, and Twitter Bootstrap.
Built with jQuery and CoffeeScript, this page reflects the web development practices of its time rather than the Hotwire-based approach I use today.
Progress Bars are essential for long-running tasks that require communication to the user their request was received and is being processed. For example, many applications allow importing spreadsheets or CSV files which could take several minutes to process. These applications save the uploaded file, start a background worker, and allow the client to track its progress.
Two approaches handle this problem: pinging and websockets. Here, I'll tackle the simpler pinging approach.
Click Start to watch a simulated background job report its progress.
This webpage is not hosted by Rails, so the above UX is just a simulation.
jquery_ujs.js recognizes its data-remote and data-type attributes and requests javascript from Rails via AJAX.progress_bar into ActiveJob and returns javascript that starts asking Rails for updates.ActiveJob updates progress_bar.message and progress_bar.percent as it processes the data until it finishes.progress_bar.percent == 100 the javascript stops asking Rails for updates.# app/models/progress_bar.rb
class ProgressBar < ApplicationRecord
belongs_to :user
validates_presence_of :user_id, :message
validates_numericality_of :percent,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
end
# rails g migration create_progress_bars
class Migration < ActiveRecord::Migration[5.0]
def change
create_table :progress_bars do |t|
t.text :message
t.integer :percent
t.integer :user_id
t.timestamps null: false
end
add_index :progress_bars, :user_id
end
end
# config/routes.rb
resources :progress_bars, only: "show"
resources :demos, only: %w[new create]
# app/controllers/demos_controller.rb
class DemosController < ApplicationController
def create
@progress_bar = current_user.progress_bars.create!(message: "Queued", percent: 0)
DemoWorker.perform_later(@progress_bar)
end
end
<!-- app/views/demos/new.html.erb -->
<div id="demo" data-ping-time="1000">
<%= link_to 'Start', url_for(action: 'create'), 'data-remote' => true, 'data-type' => 'script' %>
<div class="progress">
<div class="progress-bar" style="width: 0%;"></div>
</div>
<div class="message">Not Running...</div>
</div>
// app/views/demos/create.js.erb
(function() {
var progressBar = new ProgressBar("#demo", "<%= progress_bar_path @progress_bar %>");
progressBar.start();
})();
# app/javascripts/progress_bar.coffee
class @ProgressBar
constructor: (elem, url) ->
@elem = $(elem)
@url = url
@message = @elem.find('.message')
@bar = @elem.find('.progress-bar')
@pingTime = parseInt(@elem.data('ping-time'))
start: =>
$.ajax({
url: @url,
dataType: 'json',
success: (data) =>
@message.html(data.message)
percent = "#{data.percent}%"
@bar.css('width', percent).html(percent)
if data.percent < 100
setTimeout(@start, @pingTime)
})
# app/controllers/demos_controller.rb
class DemosController < ApplicationController
def create
@progress_bar = current_user.progress_bars.create!(message: "Queued", percent: 0)
DemoWorker.perform_later(@progress_bar)
end
end
# app/workers/demo_worker.rb
class DemoWorker < ApplicationJob
def perform(_progress_bar)
@progress_bars.update!(
message: "Working ...",
percent: 0,
)
10.times do |i|
sleep(1)
@progress_bar.update!(percent: i * 10)
end
@progress_bars.update!(
message: "Finished!",
percent: 0,
)
end
end
Get all that? The button inside demos#new invokes #create via AJAX, starting a client-side pinging of progress_bars#show while demo_worker#perform runs in the background.
Of course it's just a demo. A real worker would do something useful like import a data file. Here are a few more thoughts:
All easily built on top of this initial implementation, but I'll leave them to you. Feel free to contact me with any questions or comments at dan@cunning.cc.