When Turbo Streams was announced they allowed you to do a handful actions, like prepend, replace, focusing on your HTML.
Fast-forward, and through this PR it’s now possible to create your own actions. This means everything you can normally do with JavaScript you can do using Turbo Streams.
There is this great article by Marco Roth that explains the basics—check it out.
Setting the stage
I have a report feature, that gathers data, does some calculations and then creates a PDF off of it.
This is some intensive data wrangling, so it makes sense to put it into one or many background jobs. For a great UX the goal is to let the user know about the progress.
What is needed:
- report model (
status: %w[pending ready]
); - a stimulus controller to show the progress (
1..100%
); - turbo stream, with custom action, to update the progress in the view.
This article will not cover every single detail to get this functionality up and running from zero, but only touches upon the important bits needed for the requirements.
Once the report is requested by the user, respond with a turbo_stream and inject a progress bar.
turbo_stream.prepend("reports", partial: "reports/in_progress")
This will add the reports/_in_progress.html.erb
add top of the #reports
element.
<div>
<h3>Fasten your seatbelt! We're turbo-charging a dazzling data display just for you.</h3>
<span data-controller="progress-bar" data-progress-bar-amount-value="0" id="progress_bar" class="block w-0 h-2 bg-blue-500 rounded transition-all"></span>
</div>
This needs a Stimulus controller to update the progress bar. Let’s create it.
// app/javascript/controllers/progress_bar_controller.js
import { Controller } from "stimulus";
export default class extends Controller {
static values = { amount: Number }
connect() {
this.#updateProgress();
}
// private
amountValueChanged() {
this.#updateProgress();
}
#updateProgress() {
this.element.style.width = `${this.amountValue}%`;
}
}
All this Stimulus controller does is change the width of its element whenever the data-progress-bar-amount-value
changes. This is because of the amountValueChanged()
function.
Now in each step of the report creation, fire off a turbo_stream to update the amount data attribute.
Let’s imagine the following object:
# app/models/report/creator.rb
class Report::Creator
def initialize(report)
@report = report
end
def create
collect
wrangle
# …
finish!
end
private
def collect
# collect data
update_progress(amount: 1)
end
def wrangle
# wrangle data
update_progress(amount: 10)
end
# etc.
def finish!
@report.ready!
update_progress(amount: 100)
# TODO: probably update the screen with button to download the report
end
def update_progress(amount:)
action = turbo_stream_action_tag(:set_dataset_attribute, value: value)
ActionCable.server.broadcast("reports", action)
end
In each “step” the update_progress
method is called. This method needs some explanation.
First it uses set_dataset_attribute
from turbo-power. It’s a great add-on to your Turbo-powered Rails app.
It then broadcasts the created action to the “reports” stream (make sure you add it!). I’ve not stumbled upon another working solution, but am curious if you know of any.
And these are all the high-level steps needed to get a great UX for long-running background jobs. I think it’s pretty great this is now possible with almost no JavaScript written!