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.

gif of editing a brew using the inline form fields

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.

gif of editing a chat name using the inline form fields

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!