Hotwire Handbook - Part 3
Welcome to Part 3 of my Hotwire Handbook! It's been a long time coming! Sorry about that. I'm going to cover the remaining features we built into Daily Brew in this part. That's going to include global updates, inline editing tabbed content and modals.
It's been a little bit since I've worked on Daily Brew, so I apologise if there are any details that I miss. If there is anything missing, or any section which could do with extra details, please let me know.
My Hotwire Handbook is aiming to compliment the official Turbo Handbook and provide additional real world examples to help developers build cool features. It's also for me to be able to remember what I've built previously and how I did it!
Part 1 and Part 2 are also available.
Contents
Broadcasting Updates with Turbo Streams
In my opinion, this is one of the coolest things that Hotwire facilitates. It becomes so easy to create an app which really feels alive by leveraging Turbo's wrapper around web sockets to make live updates really really easy.
Global Counter
In Daily Brew we have multiple global counters, for example we show the number of times a particular coffee has been brewed. Hotwire makes updating the counter live, when any user logs a brew, a trivial matter.
In our Brew model we an after_create_commit
callback. Within this we set several turbo stream
broadcasts. You can see the
full
thing here, below is a slightly simplified version.
# app/models/brew.rb
after_create_commit do
broadcast_update_later_to(
'daily_brewers_count',
target: 'daily_brewers',
html: ActionController::Base.helpers.pluralize(User.daily_brewers.count, 'user has', plural: 'users have')
)
broadcast_update_later_to(
'daily_brews_count',
target: 'daily_brews',
html: ActionController::Base.helpers.pluralize(Brew.today.count, 'coffee')
)
end
These two broadcasts target two turbo streams which are connected to on the main page of daily brew. This could be streamlined by using the same turbo stream (benefits of hindsight and and extra year of experience!) The target is an ID in the html page, they replace the inner html of the target element.
# app/views/pages/main.html.erb
<div class="stats">
<%= turbo_stream_from "daily_brewers_count" %>
<%= turbo_stream_from "daily_brews_count" %>
<h2>
<% if Brew.today.count == 0 %>
No one has brewed today. <br>
Will you be the first?
<% else %>
<span id="daily_brewers"><%= pluralize(User.daily_brewers.count, 'user has', plural: 'users have') %></span> brewed
<span id="daily_brews"><%= pluralize(Brew.today.count, 'coffee') %></span> today
<% end %>
</h2>
</div>
When a user view the main page, they connect to the daily_brewers_count
and
daily_brews_count
turbo streams, and will see the counter update in real time when any other user logs
a brew, and the after_create_commit
callback is triggered.
As we're using HTML as the content that we're broadcasting there's nothing else to it. Turbo streams also
support a range of other actions and can send partials
and templates as data. The best explanation of broadcasts is in the broadcastable
model concern in the
turbo-rails
gem. If you read nothing else, read
this explanation!
Scoping Broadcasts
We use broadcasts extensively in Ryalto V4; notifications, chat, news feed and shifts all leverage turbo streams in different ways. Group chat's are one of the most interesting examples. When someone sends a message to a group chat, we want to broadcast that message to all other participants in that chat and make the message appear for all users who are looking at the chat.
# app/models/chat/message.rb
after_create_commit do
broadcast_append_to chat, target: 'messages', partial: 'chat/messages/message'
end
Here we're broadcasting the a turbo stream called "chat". This looks at the chat which the
message belongs to and users the object to name the turbo stream. We use erb to create this simply as <%=
turbo_stream_from chat %>
this then renders in a the HTML with a unique identifier.
When a message is created, we also do a lot of other things, one of which is to update the chat list for each user in the chat. If the user is not currently viewing that chat they can see that they have been sent a new message. For this we look at the chat users and broadcast to each user's chat list. Chats are also scoped to a specific organisation so we use both the user_id and the organisation_id to name the particular turbo stream.
# app/models/chat.rb
users.each do |user|
broadcast_remove_to(
"chat_list_#{user.id}_#{organisation_id}",
target: "list-chat_#{id}"
)
broadcast_prepend_to(
"chat_list_#{user.id}_#{organisation_id}",
target: "chat_list",
partial: "chats/chat_list_item",
locals: { chat: self }
)
end
In order to make the most recent chat appear at the top of the list we first remove the chat from the list and then
prepend it to the top of the list. In the same way as earlier, we subscribe to this turbo stream with erb <%=
turbo_stream_from "chat_list_#{current_user.id}_#{current_user.current_organisation.id}" %>
Inline Editing
Inline editing makes use of Turbo Frames to allow the user to update a model without leaving the show view.
I'm going to talk through two examples of this. One from Daily Brew and one from Ryalto V4.
Editing a Brew
When you view a brew, you can quickly edit the notes and the rating inline, or you can open the full edit from and make other changes. We did this as notes and rating are very likely to be updated after the user has made a brew and is drinking their coffee, where as the other fields are less likely to be edited.
In our _brew
partial, if you're looking at someone else's brew we simply load the fields.
# app/views/brews/_brew.html.erb
<div class="notes field">
<div class="form-input"><%= simple_format(brew.notes) %></div>
<div class="form-label">Notes</div>
</div>
<div class="rating field">
<div class="form-input"><%= brew.rating || '?' %>/10</div>
<div class="form-label">Rating</div>
</div>
However if you look at your own brew (if brew.user == current_user
), we do a bit more.
# app/views/brews/_brew.html.erb
<div class="notes field">
<%= form_with model: brew, data: { turbo_frame: "#{dom_id(brew)}_notes" } do |form| %>
<%= turbo_frame_tag "#{dom_id(brew)}_notes", class: "inline-edit" do %>
<%= link_to edit_brew_path(brew) do %>
<div class="form-input"><%= simple_format(brew.notes) %></div>
<div class="form-label">Notes</div>
<% end %>
<% end %>
<% end %>
</div>
<div class="rating field">
<%= form_with model: brew, data: { turbo_frame: "#{dom_id(brew)}_rating" } do |form| %>
<%= turbo_frame_tag "#{dom_id(brew)}_rating", class: "inline-edit" do %>
<%= link_to edit_brew_path(brew) do %>
<div class="form-input"><%= brew.rating || '?' %>/10</div>
<div class="form-label">Rating</div>
<% end %>
<% end %>
<% end %>
</div>
So when a user clicks into the field, we make a request to the edit_brew_path
and only load the content
within the matching turbo frame from the brew form. The section from the form is below.
# app/views/brews/_form.html.erb
<div class="field">
<%= turbo_frame_tag "#{dom_id(brew)}_notes" do %>
<%= form.text_area :notes, class: 'form-input', placeholder: 'Notes', autofocus: true %>
<%= form.label :notes, class: 'form-label' %>
<% if action_name == "edit" %>
<%= form.button "Update Note", class: "inline-action link link-primary" %>
<%= link_to "Cancel", brew_path(brew), class: "inline-action link link-primary" %>
<% end %>
<% end %>
</div>
This provides a really sleek interface for inline edits.
Editing a Chat Name
In Ryalto, we want to allow group chat admins to easily edit the name of their chat. We use the same approach for this. We also replace a few additional bits of information with the edit widow. So our turbo frame and form contain most of the chat header element.
# app/views/chats/_chat_header.html.erb
<div class="chat-header chat-box">
<%= form_with model: chat, data: { turbo_frame: "#{dom_id(chat)}_title" } do %>
<%= turbo_frame_tag "#{dom_id(chat)}_title", class: "inline-edit" do %>
<div class="chat-information">
<div class="chat-details">
<h4 class="truncate" id="chat_title">
<%= chat.name %>
</h4>
<p class="truncate"><%= chat.users.map { |user| user == current_user ? "You" : user.full_name }.to_sentence %></p>
</div>
<% if chat.admin?(current_user) && chat.group_chat %>
<%= link_to edit_chat_path(chat), class: "btn btn-icon btn-tertiary" do %>
<%= inline_svg_tag "icons/edit-filled.svg" %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
</div>
So when the user clicks the "edit" icon, we hit the edit action, which loads the edit partial and chat
form. Then we only load in the "#{dom_id(chat)}_title"
turbo frame.
# app/views/chats/_form.html.erb
<div id="chat_title_field" class="field hidden">
<%= turbo_frame_tag "#{dom_id(chat)}_title" do %>
<%= content_tag(:h5, "update chat title") if action_name == "edit" %>
<%= form.text_field :title, class: 'form-input', placeholder: 'Give your chat a name?' %>
<% if action_name == "new" %>
<%= form.label "Give your group chat a title?", class: 'form-label' %>
<% end %>
<% if action_name == "edit" %>
<div class="btns-wrapper row">
<%= button_tag "Update Title", class: "btn btn-primary btn-tran-device" do %>
<i class="fa-solid fa-check"></i>
<span>update title</span>
<% end %>
<%= link_to chat_path(chat), class: "btn btn-tertiary btn-tran-device c-danger" do %>
<i class="fa fa-times" aria-hidden="true"></i>
<span>cancel</span>
<% end %>
</div>
<% end %>
<% end %>
<p class="field-hint">Your chat name can be edited later too</p>
</div>
You can see here we have a field_hint
paragraph tag that's outside of the turbo frame, so this only
gets loaded when the full form is loaded, and not when the user is just editing the chat title.
In both of these examples we have conditionals which look at the request action name. This is to include additional buttons and content depending on the action, such as a cancel button when the user is editing. The cancel button makes a request back to the the show chat path, and then only the content within the matching turbo frame is loaded again.
Modals
We use this same principle for modals.
In our application.html.erb
we have an empty "modal" turbo frame. <%= turbo_frame_tag
"modal" %>
, then when we want to load something into that modal, we wrap the content in the
turbo frame tag.
# app/views/brews/_brew.html.erb
<%= turbo_frame_tag "modal" do %>
<div id="<%= dom_id brew %>" data-controller="modal" data-action="keyup@window->modal#closeWithKeyboard">
<div data-action="click->modal#close" class="modal-background"></div>
<div class="card column">
<%# Other content here %>
</div>
</div>
<% end %>
# app/javascript/controllers/modal_controller.js
# NOTE: This is not inside script tags, I'm just including them for code highlighting
<script>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
close() {
this.element.remove()
}
closeWithKeyboard(e) {
if (e.code === "Escape") {
this.close()
}
}
}
</script>
We can they style the turbo frame however we want, so different modals can have have different behaviours or appearances.
Hotwire makes this so simple, and the only JavaScript we have is to close the modal.
Tabbed Content
In Daily Brew, we do tabbed content in the same way. Loading different content into the same turbo frame.
In our coffee show template, we render the recent_brews
partial.
# app/views/coffee/show.html.erb
<section class="tabs">
<div class="card">
<%= render 'coffees/recent_brews', locals: { coffee: @coffee, brews: @recent_brews } %>
</div>
</section>
This partial is within a turbo frame and provides links to two other paths which load within the same turbo frame.
# app/views/coffees/_recent_brews.html.erb
<%= turbo_frame_tag "coffee_tab" do %>
<div class="links">
<h2>Recent Brews</h2>
<h3><%= link_to "Your Brews", coffee_user_brews_path(@coffee) %></h3>
<h3><%= link_to "Reviews", coming_soon_coffee_path(@coffee) %></h3>
</div>
<hr>
<% if @recent_brews.present? %>
<div class="brews-table" id="brews_table">
<%= turbo_stream_from "recent_brews" %>
<%= render 'brews/brews_table', brews: @recent_brews %>
</div>
<% else %>
<h3 class="no-brews">No one has logged brews of this coffee</h3>
<% end %>
<% end %>
# app/views/coffees/_user_brews.html.erb
<%= turbo_frame_tag "coffee_tab" do %>
<div class="links">
<h2>Your Brews</h2>
<h3><%= link_to "Recent Brews", coffee_recent_brews_path(@coffee) %></h3>
<h3><%= link_to "Reviews", coming_soon_coffee_path(@coffee) %></h3>
</div>
<hr>
<% if @user_brews.present? %>
<div class="brews-table" id="brews_table">
<%= render 'brews/brews_table', brews: @user_brews %>
</div>
<% else %>
<h3 class="no-brews">You haven't logged any brews for this coffee </h3>
<% end %>
<% end %>
# app/views/coffees/_reviews.html.erb
<%= turbo_frame_tag "coffee_tab" do %>
<div class="links">
<h2>Reviews</h2>
<h3><%= link_to "Recent Brews", coffee_recent_brews_path(@coffee) %></h3>
<h3><%= link_to "Your Brews", coffee_user_brews_path(@coffee) %></h3>
</div>
<hr>
<div class="coming-soon">
<h2>Coming Soon</h2>
<p>Soon, we're going to be adding a load of extra information here.</p>
<p>You'll be able to see <s>your brews of this coffee, all public brews, and</s> all reviews.</p>
<p>If you have any suggestions as to what you would like to see, then please let us know</p>
</div>
<% end %>
Tabbed content with 0 custom JavaScript.
In Ryalto, we do it in the exact same way. The only difference is that our tabs are based on filters that are applied in the controller. The controls for the filter are outside of the turbo frame, so after they're applied we only reload the tabbed content, not the controls which made it change.
I think that's everything for Part 3. I don't have anything else I want to cover in a part 4 so this might be the end! Please get in touch if there is anything that you'd like to see added!