Introduction
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.
Demo
Click to the following button to see the progress bar go Start
This webpage is not hosted by Rails, so the above UX is just a simulation.
How it works
- When start is clicked,
jquery_ujs.js
recognizes itsdata-remote
anddata-type
attributes and requests javascript from Rails via AJAX. - Rails receives the request, passes a new
progress_bar
intoActiveJob
and returns javascript that starts asking Rails for updates. - The
ActiveJob
updatesprogress_bar.message
andprogress_bar.percent
as it processes the data until it finishes. - When
progress_bar.percent == 100
the javascript stops asking Rails for updates.
The Code
# 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
Wrap-Up
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:
- Should you use Redis for ephemeral data like progress bars?
- How do you notify the user of errors that may occur inside your worker?
- Do you want the progress bar to popup in a modal?
- Would you allow multiple progress bars on the same screen?
- Can the user cancel the background process?
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.