Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Showing progress of background jobs with Turbo ...

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →

Showing progress of background jobs with Turbo and Stimulus

Demonstration how to broadcast progress of background jobs to frontend.
Presentation with lots of Chuck Norris jokes!

Avatar for Michal L

Michal L

June 18, 2024

More Decks by Michal L

Other Decks in Programming

Transcript

  1. The app Two models: Joke belongs to JokeRequest Job fetching

    number of jokes from Chuck Norris API “Chuck Norris doesn't have a watch, HE decides what time it is.”
  2. 1. Broadcast from model class class Joke Joke < <

    ApplicationRecord ApplicationRecord after_create_commit after_create_commit -> ->( (joke joke) ) do do broadcast_replace_to broadcast_replace_to([ ([ joke joke. .jokes_request jokes_request, , "jokes_progress_bar" "jokes_progress_bar" ], ], target: target: "jokes_progress_bar" "jokes_progress_bar", , partial: partial: "jokes_requests/jokes_progress_bar" "jokes_requests/jokes_progress_bar", , locals: locals: { { jokes_request: jokes_request: joke joke. .jokes_request jokes_request } } ) ) end end belongs_to belongs_to :jokes_request :jokes_request end end
  3. # app/views/jokes_requests/_jokes_progress_bar.html.erb # app/views/jokes_requests/_jokes_progress_bar.html.erb <% <% progress_width progress_width = =

    jokes_request jokes_request. .jokes jokes. .count count / / jokes_request jokes_request. .amount amount. .to_f to_f * * 100 100 %> %> <div id="jokes_progress_bar" class="w-full bg-gray-200 rounded-full"> <div id="jokes_progress_bar" class="w-full bg-gray-200 rounded-full"> < <div div class class= ="h-0.5 bg-lime-500 rounded-full" "h-0.5 bg-lime-500 rounded-full" style style= ="width: <%= progress_width.to_i %>%;" "width: <%= progress_width.to_i %>%;"></ ></div div> > </ </div div> > 1. Broadcast from model # app/views/jokes_requests/show.html.erb # app/views/jokes_requests/show.html.erb <%= <%= turbo_stream_from turbo_stream_from @jokes_request @jokes_request, , "jokes_progress_bar" "jokes_progress_bar" %> %> # (..) # (..) <%= <%= render render "jokes_progress_bar" "jokes_progress_bar", , jokes_request jokes_request: : @jokes_request @jokes_request %> %>
  4. PROS Fast Easy CONS Callbacks Polluting model CRUD operations with

    broadcasts Not always applicable 1. Broadcast from model Chuck Norris can nail a hammer into a wall.
  5. 2. Broadcast from worker # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def

    update_progress_bar update_progress_bar( (number number) ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes_progress_bar" "jokes_progress_bar" ], ], target: target: "jokes_progress_bar" "jokes_progress_bar", , partial: partial: "jokes_requests/jokes_progress_bar" "jokes_requests/jokes_progress_bar", , locals: locals: { { actual: actual: number number, , limit: limit: jokes_request jokes_request. .amount amount } } ) ) end end
  6. 2. Broadcast from worker # app/views/jokes_requests/_jokes_progress_bar.html.erb # app/views/jokes_requests/_jokes_progress_bar.html.erb <% <%

    progress_width progress_width = = actual actual / / limit limit. .to_f to_f * * 100 100 %> %> <div id="jokes_progress_bar" class="w-full bg-gray-200 rounded-full"> <div id="jokes_progress_bar" class="w-full bg-gray-200 rounded-full"> < <div div class class= ="h-0.5 bg-lime-500 rounded-full" "h-0.5 bg-lime-500 rounded-full" style style= ="width: <%= progress_width.to_i %>%;" "width: <%= progress_width.to_i %>%;"></ ></div div> > </ </div div> > # app/views/jokes_requests/show.html.erb # app/views/jokes_requests/show.html.erb <%= <%= turbo_stream_from turbo_stream_from @jokes_request @jokes_request, , "jokes_progress_bar" "jokes_progress_bar" %> %> # (..) # (..) <%= <%= render render "jokes_progress_bar" "jokes_progress_bar", , actual actual: : @jokes_request @jokes_request. .jokes jokes. .count count, , limit limit: : @jokes_request @jokes_request. .amount amount %> %>
  7. PROS No callbacks Easy to test Broadcast limited to one

    source place CONS More work? Still needs to bind to some record/ unique user ID 2. Broadcast from worker
  8. 3. Pseudo-background job # app/views/jokes_request/show.html.erb # app/views/jokes_request/show.html.erb <% <% jokes_request

    jokes_request. .amount amount. .times times do do %> %> <%= turbo_frame_tag "joke", src: fetch_joke_path do %> <%= turbo_frame_tag "joke", src: fetch_joke_path do %> <% <% end end %> %> <% end %> <% end %>
  9. 3. Pseudo-background job # app/controllers/jokes_controller.rb # app/controllers/jokes_controller.rb def def fetch_joke

    fetch_joke @joke @joke = = FetchJokeService FetchJokeService. .new new. .call call end end # app/views/jokes/fetch_joke.html.erb # app/views/jokes/fetch_joke.html.erb <%= <%= turbo_frame_tag turbo_frame_tag "joke" "joke" do do %> %> <div class="bg-slate-200 text-slate-700 text-center text-clip overflow-clip py-4 mb-4 shadow-md <div class="bg-slate-200 text-slate-700 text-center text-clip overflow-clip py-4 mb-4 shadow-md rounded px-4"> rounded px-4"> <%= <%= @joke @joke %> %> </div> </div> <% <% end end %> %>
  10. 4. Updates with Stimulus <!-- app/views/jokes_requests/show.html.erb --> <!-- app/views/jokes_requests/show.html.erb -->

    <!-- just one stream is needed now --> <!-- just one stream is needed now --> < <% %= = turbo_stream_from turbo_stream_from @jokes_request, @jokes_request, "jokes" "jokes" % %> > < <div div id id= ="jokes_show" "jokes_show" data-controller data-controller= ="progress-bar" "progress-bar" data-progress-bar-limit-value data-progress-bar-limit-value= ="<%= @jokes_request.amount %>" "<%= @jokes_request.amount %>" data-progress-bar-actual-value data-progress-bar-actual-value= ="<%= @jokes_request.jokes.size %>" "<%= @jokes_request.jokes.size %>"> > <!-- rest of the html --> <!-- rest of the html --> <!-- (...) --> <!-- (...) --> < <% %= = render render "jokes_progress_bar" "jokes_progress_bar", , actual: actual: @jokes_request.jokes.size, @jokes_request.jokes.size, limit: limit: @jokes_request.amount @jokes_request.amount % %> > < <% %= = turbo_frame_tag turbo_frame_tag "jokes" "jokes" do do % %> > < <div div id id= ='jokes_grid' 'jokes_grid' data-progress-bar-target data-progress-bar-target= ="jokesGrid" "jokesGrid" class class= ="grid grid-cols-3 gap-4 mt-4" "grid grid-cols-3 gap-4 mt-4"> > < <% % @jokes.each @jokes.each do do |joke| |joke| % %> > < <% %= = render render 'jokes/joke' 'jokes/joke', , joke: joke: joke joke % %> > < <% % end end % %> > </ </div div> > < <% % end end % %> >
  11. 4. Updates with Stimulus // app/javascript/controllers/progress_bar_controller.js // app/javascript/controllers/progress_bar_controller.js import import

    { { Controller Controller } } from from "@hotwired/stimulus" "@hotwired/stimulus" // Connects to data-controller="progress-bar" // Connects to data-controller="progress-bar" export export default default class class extends extends Controller Controller { { static static values values = = { { limit limit: : 0 0, , actual actual: : 0 0, , } } static static targets targets = = [ ["progress" "progress", , "count" "count"] ] connect connect() { () { // (...) // (...) } } } }
  12. 4. Updates with Stimulus export export default default class class

    extends extends Controller Controller { { // (..) // (..) connect connect() { () { addEventListener addEventListener( ("turbo:before-stream-render" "turbo:before-stream-render", (( , ((event event) ) => => { { const const fallbackToDefaultActions fallbackToDefaultActions = = event event. .detail detail. .render render event event. .detail detail. .render render = = ( (streamElement streamElement) ) => => { { if if ( (streamElement streamElement. .action action === === "append" "append" && && streamElement streamElement. .target target === === "jokes_grid" "jokes_grid") { ) { this this. .increment increment() () } } fallbackToDefaultActions fallbackToDefaultActions( (streamElement streamElement) ) } } })) })) } } } }
  13. 4. Updates with Stimulus export export default default class class

    extends extends Controller Controller { { increment increment() { () { this this. .actualValue actualValue++ ++ this this. .updateProgress updateProgress() () this this. .updateCount updateCount() () } } updateProgress updateProgress() { () { let let progress progress = = ( (this this. .actualValue actualValue / / this this. .limitValue limitValue) ) * * 100 100 this this. .progressTarget progressTarget. .style style. .width width = = `${ `${progress progress}%` }%` } } updateCount updateCount() { () { this this. .countTarget countTarget. .innerText innerText = = `${ `${this this. .actualValue actualValue} } / ${ / ${this this. .limitValue limitValue}` }` } } } }
  14. Sometimes you just need to move more logic to the

    frontend 4. Updates with Stimulus
  15. Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb class class FetchJokesService FetchJokesService

    # ... # ... def def add_joke add_joke( (joke joke, , jokes_count jokes_count) ) last_page last_page = = jokes_count jokes_count / / Joke::PER_PAGE Joke::PER_PAGE + + 1 1 if if jokes_count jokes_count % % Joke::PER_PAGE Joke::PER_PAGE == == 1 1 # change page to next one # change page to next one clear_all_jokes clear_all_jokes add_joke_card add_joke_card( (joke joke) ) replace_pagination replace_pagination( (jokes_count jokes_count, , last_page last_page) ) else else add_joke_card add_joke_card( (joke joke) ) end end end end end end
  16. Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def clear_all_jokes clear_all_jokes

    Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes" "jokes" ], ], target: target: "jokes_grid" "jokes_grid", , partial: partial: "jokes_requests/empty_jokes_grid" "jokes_requests/empty_jokes_grid" ) ) end end # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def add_joke_card add_joke_card( (joke joke) ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_append_to broadcast_append_to( ( [ [ jokes_request jokes_request, , "jokes" "jokes" ], ], target: target: "jokes_grid" "jokes_grid", , partial: partial: "jokes/joke" "jokes/joke", , locals: locals: { { joke: joke: joke joke } } ) ) end end
  17. Bonus: PAGINATION # app/services/fetch_jokes_service.rb # app/services/fetch_jokes_service.rb def def replace_pagination replace_pagination(

    (jokes_count jokes_count, , last_page last_page) ) pagy pagy = = Pagy Pagy. .new new( (count: count: jokes_count jokes_count, , page: page: last_page last_page, , items: items: Joke::PER_PAGE Joke::PER_PAGE, , link_extra: link_extra: 'data-turbo-action="advance"' 'data-turbo-action="advance"') ) Turbo::StreamsChannel Turbo::StreamsChannel. .broadcast_replace_to broadcast_replace_to( ( [ [ jokes_request jokes_request, , "jokes_pagination" "jokes_pagination" ], ], target: target: "jokes_pagination" "jokes_pagination", , partial: partial: "jokes_requests/jokes_pagination" "jokes_requests/jokes_pagination", , locals: locals: { { pagy: pagy: pagy pagy } } ) ) end end