Recently, I had to implement a multi-screen slide deck presentation system that stayed in sync with the presenter’s view.
How did I get there?
Requirements
Render three views: the group view, the individual view, and the presenter view.
- The group view should show a fullscreen view of a slide, including group content such as a video
- The individual view should show a fullscreen view of a slide with the option to take actions such as filling out a form, but should hide group content
- The presenter view should show a “preview” of the previous, current, and next slides
- The presenter view can navigate forward, backward, or jump to any slide
and most importantly, all views have to stay in sync with the presenter’s current slide.
Also, this isn’t syncing only three instances. In our environment it was around 30, as we had that many people with the individual view running on their laptop!
Routes and views
The foundation of the system is a Presentation
table with a #current_slide
column.
To simplify things a bit, the system supports three routes:
/presentations/:id
/presentations/:id/group
/presentations/:id/individual
Without getting into too much detail, the group and individual routes have a
show.html.erb
that uses a @presentation
reference to display the content
for the current slide.
The presenter view renders three iframes of the group view:
- one for
#current_slide - 1
- one for
#current_slide
- one for
#current_slide + 1
The iframes are wrapped in buttons that update #current_slide
and use
pointer-events: none
in the CSS to prevent the presenter from accidentally interacting with
the previews.
But how to keep all the views in sync?
Can Hotwire help us? Yes!
We can leverage
broadcasting page refreshes
by adding broadcasts_refreshes
to our Presentation
model and
turbo_stream_from
to our views.
Getting it working in Development
To tie everything together, we have to make a handful of one- or two-line changes to:
- the model
- the view
- the layout
# app/models/presentation.rb
class Presentation < ApplicationRecord
broadcasts_refreshes
<!-- app/views/presentations/group/show.html.erb -->
<%= turbo_stream_from @presentation %>
<div id="<%= dom_id(@presentation) %>">
<div>Group View: Slide <%= @presentation.current_slide %></div>
<!-- app/views/presentations/individual/show.html.erb -->
<%= turbo_stream_from @presentation %>
<div id="<%= dom_id(@presentation) %>">
<div>Individual View: Slide <%= @presentation.current_slide %></div>
We also need to update our Application
layout to
refresh with morphing:
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html lang="en">
<head>
...
<%= csp_meta_tag %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Using content_for
instead of an application-wide layout
Some sites won’t want Turbo refreshes on all pages. To enable morphing on a specific view only,
use content_for
in a view’s html.erb
file.
<%= content_for :head do %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<% end %>
Support the content_for
in the Application layout with a yield
:
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html lang="en">
<head>
...
<%= csp_meta_tag %>
<%= yield :head %>
The broadcasting part
With this setup, when Presentation#current_slide
is updated, the change is broadcast to the
listening views, and they magically automatically show the content for the current slide!
The overall approach requires updating the Presentation
record, but more specifically,
it relies on an ActiveRecord callback. At first, we tried the seemingly-convenient
increment!
and
decrement!
methods to update the current slide.
The issue with these (and a few other ActiveRecord methods) is that they do not trigger callbacks. And so, they never broadcast a change, and the views never update!
Getting it working in Test
Here’s a simple system test to verify that the broadcasting is working as expected:
# test/system/presentation_test.rb
test "/presentations/:id/group dynamically updates its content" do
presentation = create :presentation, current_slide: 1
visit presentation_group_path(presentation)
assert_text "Slide 1"
presentation.update!(current_slide: 2)
assert_text "Slide 2"
end
…but if you run this on a default Rails setup, it will never work. Why? Because behind the scenes, the broadcasts are handled by jobs. And in Test, jobs are configured by default to use the TestAdapter.
So, you have two choices:
- Use the
:async
adapter in test, include theActiveJob::TestHelper
in your test file, and use aperform_enqueued_jobs
block around the#update!
call:
# config/environments/test.rb
config.active_storage.service = :async
# test/system/presentation_test.rb
include ActiveJob::TestHelper
test "/presentations/:id/group dynamically updates its content" do
presentation = create :presentation, current_slide: 1
visit presentation_group_path(presentation)
assert_text "Slide 1"
perform_enqueued_jobs do
presentation.update!(current_slide: 2)
end
assert_text "Slide 2"
end
- or, use the
:inline
adapter but swap it out for the test adapter in yourtest_helper.rb
file so thatActiveJob
tests that rely on enqueued jobs continue to work:
# config/environments/test.rb
config.active_storage.service = :inline
# test/test_helper.rb
module ActiveJob
module TestHelper
def queue_adapter_for_test
ActiveJob::QueueAdapters::TestAdapter.new
end
end
end
with no changes to the first system test example above.
Getting it working in Production (Heroku)
There are many ways you might be handling jobs in your Production environment,
but in Heroku, we had to create a separate dyno running bundle exec bin/jobs
:
Final thoughts
Thanks to broadcasts_refreshes
, turbo_stream_from
, and Turbo morphing, we can keep a
robust multi-screen system that stays in sync with the presenter’s view with only
a few tiny additions to our code.
The possibilities are endless, but the core idea is the same: any view can reflect the state of a model with Hotwire.
There are some gotchas to keep in mind, but other than that, it’s as easy as…syncing!