Leveraging Turbo 8: Best Additions to Implement in Rails 8 Projects

rob__race

Rob Race

Posted on October 1, 2024

Leveraging Turbo 8: Best Additions to Implement in Rails 8 Projects

This article will explore Turbo 8's powerful features and a little bit of code. You will be surprised by how little extra code is needed to implement all of this!

This tutorial is a distillation of the type of information you'll find in one of the two books I am writing, Realtime Rails

We will be starting over but using many of the same concepts from the previous article. Let's get into it!

Setup

First, we can create a new Rails app from the main branch to get all of the Rails 8 goodies (though, soon, this will be available through alpha/beta gem releases).

rails new --main blabber
Enter fullscreen mode Exit fullscreen mode

Now, as we will be adding a few features related to user interactions, we will need to create users and a way to log them in. Short of using the new Rails 8 authentication generators, we can add two quick models, a small number of controller methods, and a few view templates.

First, the models via Rails generators:

rails g model user email:string password_digest:string
rails g model post user:references message:text
Enter fullscreen mode Exit fullscreen mode

These generators will create two models and migrations for the Users and Posts. Let's add a few lines to the User model for validation and authentication:

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  has_secure_password

end
Enter fullscreen mode Exit fullscreen mode

The significant addition here is the has_secure_password line which includes code to set and authenticate passwords via bcrypt. Now, on the Post model, the user reference should exist, and we can add a quick validation:

class Post < ApplicationRecord
  belongs_to :user

  validates :message, length: { minimum: 1, maximum: 280 }
end
Enter fullscreen mode Exit fullscreen mode

Finally, here is a quick bin/rails db:migrate to get those tables created.

Now, we can move on to the controller and views for logging in. We want to make this simple, just for this tutorial, so we'll handle logging in and logging out, their responsive routes, and some basic views.

First, the routes:

Rails.application.routes.draw do
  get "/login", to: "sessions#new", as: "login"
  post "/sessions", to: "sessions#create"
  get "/logout", to: "sessions#destroy", as: "logout"

  resources :posts, only: %i[index create]

  root "posts#index"
end
Enter fullscreen mode Exit fullscreen mode

These will be all the routes we need for the whole app: the first three for sessions, a resource helper for posts, and setting the root route.

Now we can add the SessionsController at app/controllers/sessions_controller.rb:

class SessionsController < ApplicationController

  def create
    @user = User.find_by(email: params.dig(:user, :email))
    if @user && @user.authenticate(params.dig(:user, :password))
      sign_in(@user)
      redirect_to root_path, notice: "You have successfully logged in!"
    else
      flash.now[:alert] = "There was a problem logging in."
      render :new, status: 422
    end
  end

  def destroy
    sign_out
    redirect_to login_path, notice: "You have successfully logged out!"
  end

  private

  def sign_in(user)
    session[:user_id] = user.id
  end

  def sign_out
    session.delete(:user_id)
  end
end
Enter fullscreen mode Exit fullscreen mode

Pretty simple here. If we find a user and that user authenticates with the included authenticate method from the User model, we will call sign_in, which sets a session for the user. Real quick, we can add a helper method in ApplicationController to use current_user throughout controllers and views through the app:

  def current_user
    @current_user = User.find_by(id: session[:user_id]) if session[:user_id]
  end
  helper_method :current_user
Enter fullscreen mode Exit fullscreen mode

Next, we can quickly add a PostsController that resembles much of what we used in the last tutorial, aside from now having a user_id:

class PostsController < ApplicationController
  before_action :require_login

  def index
    @posts = Post.all.order(created_at: :desc)
    @post = Post.new
  end

  def create
    @post = Post.new(post_params.merge(user_id: current_user.id))
    respond_to do |format|
      if @post.save
        redirect_to posts_path
      else
        render :index
      end
    end
  end

  private

  def post_params
    params.require(:post).permit(:message)
  end
end
Enter fullscreen mode Exit fullscreen mode

Both the index and create actions are reasonably straightforward Rails-like methods for listing and creating Posts. The last part of the authentication puzzle here would be to add the before_action:require_login method to the ApplicationController and use the Rails Console to create two users (thus, we skipped building a signup interface).

# app/controllers/application_controller.rb


## redirect to login, if there is no current_user, meaning there is no authenticated user session
def require_login
  redirect_to login_path, alert: 'You must be logged in to access this page.' if current_user.nil?
end
Enter fullscreen mode Exit fullscreen mode

Lastly, we can go ahead and knock out a few views to get all of the authentication, Bootstrap, and basic Post markup out of the way

<!DOCTYPE html>
<html>
  <head>
    <title><%= content_for(:title) || "Blabber" %></title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="mobile-web-app-capable" content="yes">
    <% if current_user %>
      <meta name="current-user-id" content="<%= current_user.id %>">
    <% end %>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
    <%= yield :head %>

    <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
    <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>

    <link rel="icon" href="/icon.png" type="image/png">
    <link rel="icon" href="/icon.svg" type="image/svg+xml">
    <link rel="apple-touch-icon" href="/icon.png">

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" type="text/css" />
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>

    <%# Includes all stylesheet files in app/views/stylesheets %>
    <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body class="">
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
         <div class="container-fluid">
           <a class="navbar-brand" href="/">Blabber</a>
           <span class="navbar-text">
             <% if current_user %>
               <%= current_user.email %>
               <%= link_to "Logout", logout_path, method: :delete, class: "btn btn-outline-danger" %>
             <% else %>
               <%= link_to "Login", login_path, class: "btn btn-outline-primary" %>
             <% end %>
            </span>
          </div>
      </nav>

    <% if flash[:notice] %>
      <div class="alert alert-success" role="alert">
        <%= flash[:notice] %>
      </div>
    <% end %>
    <% if flash[:alert] %>
      <div class="alert alert-danger" role="alert">
        <%= flash[:alert] %>
      </div>
    <% end %>
    <div class="container mt-5">
      <%= yield %>
    </div>

  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

A few code blocks to note:

<% if current_user %>
  <meta name="current-user-id" content="<%= current_user.id %>">
<% end %>
Enter fullscreen mode Exit fullscreen mode

...will add the user ID to the front end for use in StimulusJS later.

<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Enter fullscreen mode Exit fullscreen mode

...will allow Turbo 8 to morph the incoming DOM changes sent by Turbo.

Finally, Bootstrap is added over CDN for the simplicity of this tutorial, as well as a simple navbar and alerts.

Ok, three Post templates, and we're done with setup.

To display the list of posts, you can create an index.html.erb file in the app/views/posts directory. Here's an example of how the index view could look like:

<div class="container">
  <h1>Blabber</h1>
  <h4>A Rails, Hotwire demo</h4>

  <%= render partial: 'form' %>

  <%= render @posts %>
</div>
Enter fullscreen mode Exit fullscreen mode

This code uses the _post.html.erb partial to render each post in the @posts collection.

<div class="card mb-2" id="<%= dom_id(post) %>">
  <div class="card-body">
    <h5 class="card-title text-muted">
      <small class="float-right">
        Posted <%= time_ago_in_words(post.created_at) %> ago
      </small>
      <%= post.user.email %>
    </h5>
    <div class="card-text lead mb-2"><%= post.message %></div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

...and then the _form.html.erb

<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
  <% if @post.errors.any? %>
    <div id="error_explanation">
      <h2>
        <%= pluralize(@post.errors.count, "error") %> prohibited this post from
        being saved:
      </h2>

      <ul>
        <% @post.errors.full_messages.each do |msg| %>
        <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div class="form-group" data-controller="typing">
    <%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control',rows: 3 %>
  </div>

  <div class="actions my-2">
    <%= f.submit class: "btn btn-primary" %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

This code uses the form_with helper to create a form for the @post object. The text_area helper creates a textarea input for the post's message attribute.

Ok! That's enough setup code!

Turbo Morph

If you followed along with the previous tutorial, there are only a couple of lines to add to get morphed updates out of the box! Earlier, we pre-empted the code to add with the line to the partial. To finish this off, we will add a line to the Post model and two through the Post views.

First, the line to the model:

class Post < ApplicationRecord
  belongs_to :user

  broadcasts_refreshes ## New line

  validates :message, length: { minimum: 1, maximum: 280 }
end
Enter fullscreen mode Exit fullscreen mode
<div class="container">
  <h1>Blabber</h1>
  <h4>A Rails, Hotwire demo</h4>

  <%= render partial: 'form' %>


  <%= turbo_stream_from 'posts' %> <!-- This is that streams from the post collection -->
  <%= render @posts %>
</div>
Enter fullscreen mode Exit fullscreen mode
<%= turbo_stream_from post %> <!-- This is that streams from each post -->
<div class="card mb-2" id="<%= dom_id(post) %>">
  <div class="card-body">
    <h5 class="card-title text-muted">
      <small class="float-right">
        Posted <%= time_ago_in_words(post.created_at) %> ago
      </small>
      <%= post.user.email %>
    </h5>
    <div class="card-text lead mb-2"><%= post.message %></div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

That's it! If you create two users in the console, log on in, and then log another in on an incognito tab, you will see each page get updated with new posts from each user!

Presence Channel

Presence channels are a great way to show who is online in your application.

In our case, we will use an indicator next to the users' email addresses in the Post card. This indicator will be a simple green dot if the user is online and a red dot if the user is offline.

The first step here will be to include icons to use a circle and then color it based on the user's presence. We can easily include Bootstrap icons by adding the following line to the application.css file:

@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css");
Enter fullscreen mode Exit fullscreen mode

Now, we can generate a quick migration to add an online boolean to the User model:

rails g migration add_online_to_users online:boolean
rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Next, we can add two simple methods to the User to adjust a user's presence status using the online boolean. We will also add a method to broadcast changes to the presence channel after a user's status changes and check for the online_previously_changed? (a built-in ActiveRecord field status method) in that method used by the callback:

class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  has_secure_password

  after_commit :broadcast_changes

  def broadcast_changes
    if online_previously_changed?
      Turbo::StreamsChannel.broadcast_refresh_to(:presence)
    end
  end

  def came_online
    update!(online: true) unless online?
  end

  def went_offline
    update!(online: false) if online?
  end
end
Enter fullscreen mode Exit fullscreen mode

Finally, we can add a line to the application.html.erb to listen for changes to the presence channel and then update the _post partial to include the presence indicator:

...
 <%= turbo_stream_from :presence, channel: PresenceChannel if current_user %>
...
Enter fullscreen mode Exit fullscreen mode

The turbo_stream_from:presence will use the turbo_stream helpers from the turbo-rails gem to handle the automatic subscription to the presence channel. The PresenceChannel argument in this method sets which Channel will be specifically used in the streaming process.

<%= turbo_stream_from post %>
<div class="card mb-2" id="<%= dom_id(post) %>">
  <div class="card-body">
    <h5 class="card-title text-muted">
      <small class="float-right">
        Posted <%= time_ago_in_words(post.created_at) %> ago
      </small>
      <i class="bi bi-circle-fill align-middle text <%= post.user.online? ? 'text-success' : 'text-danger'%>" style="font-size: .5rem"></i>
      <%= post.user.email %>
    </h5>
    <div class="card-text lead mb-2"><%= post.message %></div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This uses the bi-circle-fill icon from Bootstrap and changes its color based on the user's presence status.

Lastly, we can create the PresenceChannel in the app/channels directory:

class PresenceChannel < ActionCable::Channel::Base
      extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
      include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    stream_from "presence"
    current_user.came_online
  end

  def unsubscribed
    current_user.went_offline
  end
end
Enter fullscreen mode Exit fullscreen mode

This channel extends and includes some Turbo::Streams modules, so that it can broadcast to the presence channel and handle the stream name. The subscribed method will stream from the presence channel and then call the came_online method on the current user. The unsubscribed method will call the went_offline method on the current user. unsubscribe is a method that is called when the client disconnects from the channel by closing the window or logging out.

Now, if you tried to use the browser, you would see errors from your channel in the logs, as we have to set up one last piece, the current_user in the Connection class. This class is much like an ApplicationController but for the ActionCable connections. We can add a method to the Connection class to set the current_user:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private
      def find_verified_user
        if current_user = User.find_by(id: @request.session[:user_id])
          current_user
        else
          reject_unauthorized_connection
        end
      end
  end
end
Enter fullscreen mode Exit fullscreen mode

This module will set the current_user to the user found by the session[:user_id], which was set during the login, or reject the connection if the user is not found.

If you were to open two browsers (your current browser window and an incognito would work!) now and log into each user, you would see the presence indicator change in real time!

Note: This presence could become noisy with a lot of users, specifically because it will be writing a lot to the users table. This example was built for simplicity and not scale. With enough traffic on a real application, you may want to consider a second database or technology to store the data (e.g., Redis).

Typing Indicators

The last feature we'll build here is a typing indicator, which will show when a user is typing a message, much like you see in Slack. Of course, as this is just a simple app to demonstrate the capability, the typing indicator will be shown to all users but not the user typing.

First, we can generate a channel to handle the typing events and pin a debounce library to handle the typing events in a javascript controller a few files later:

bin/rails generate channel typing
bin/importmap pin lodash.debounce
Enter fullscreen mode Exit fullscreen mode

Inside the TypingChannel, we can add a few methods to handle the typing and typing stopped events from the "Cable" side:

class TypingChannel < ApplicationCable::Channel
  def subscribed
    stream_from "typing"
  end

  def typing
    ActionCable.server.broadcast("typing", { action: 'typing', uid: current_user.id.to_s, user_email: current_user.email } )
  end

  def typing_stopped
    ActionCable.server.broadcast("typing", { action: 'typing_stopped', uid: current_user.id.to_s, user_email: current_user.email } )
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end
Enter fullscreen mode Exit fullscreen mode

This channel will stream from the typing channel and then broadcast the typing and typing_stopped events to the channel. The typing event will include the current user's uid and user_email. This information is needed to do two things. One, the user_id will be used to filter out the currently typing user from seeing the typing indicator. Second, the email address is used to display the message so as not to send the data to the front end differently. The unsubscribed method was added to the channel by default during the generation of the channel but can be removed as it is not needed.

With that out of the way, the next place to add some code is the markup by adjusting and adding some StimulusJS to the Posts form:

<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
  <% if @post.errors.any? %>
    <div id="error_explanation">
      <h2>
        <%= pluralize(@post.errors.count, "error") %> prohibited this post from
        being saved:
      </h2>

      <ul>
        <% @post.errors.full_messages.each do |msg| %>
          <li><%= msg %></li>
        <% end %>
      </ul>
    </div>
  <% end %>
  <div class="form-group">
  <div class="form-group" data-controller="typing">
    <%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', rows: 3 %>
    <%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', data: { turbo_permanent: true, action: 'keydown->typing#typing keyup->typing#typingStopped' }, rows: 3 %>
    <div id="typingHint" class="form-text" data-typing-target="display"></div>
  </div>

  <div class="actions my-2">
    <%= f.submit class: "btn btn-primary" %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

We replace two lines, adding StimulusJS markup of <div class="form-group" and action: 'keydown->typing#typing keyup->typing#typingStopped' for the StimulusJS controller to listen for keydown and keyup events. We add turbo_permanent: true so that the turbo morph updates will not clear out the form's current state (i.e., text that has been typed or bound javascript that StimulusJS is handling). Finally, the data-typing-target="display" will be used to update the message in the view when someone is typing.

Finally, we can add the StimulusJS controller to the app/javascript/controllers directory:

import { Controller } from "@hotwired/stimulus";
import consumer from "channels/consumer";
import debounce from "lodash.debounce";

export default class extends Controller {
  static targets = ["display"];

  initialize() {
    this.typingStopped = debounce(this.typingStopped, 1000);
  }

  connect() {
    this.subscription = consumer.subscriptions.create("TypingChannel", {
      received: (data) => {
        if (data.uid != this.userId) {
          this.displayTarget.innerHTML =
            data.action == "typing" ? `${data.user_email} is typing...` : "";
        }
      },
    });
  }

  typing(_event) {
    this.subscription.perform("typing");
  }

  typingStopped(_event) {
    this.subscription.perform("typing_stopped");
  }

  get userId() {
    return document.head.querySelector("meta[name=current-user-id]")?.content;
  }
}
Enter fullscreen mode Exit fullscreen mode

This controller has many moving parts; we'll go through them by behavior.

First, we create a getter for the userId, which will get the current-user-id from the meta tag in the head of the document, which was added way earlier when we first modified the application.html.erb. This will be used later in the event filtering process.

Then, in the connect method, we create a subscription to the TypingChannel and then listen for the received event. The received happens when the broadcast is sent from the TypingChannel. We'll come back to the if statement in a moment.

In the view, we used keydown->typing#typing to call the typing function when that event happens in the browser. The typing function then calls the perform method on the subscription to send the typing event to the TypingChannel. This event triggers the typing method in the TypingChannel to broadcast the typing event to the typing channel, which is handled by the received handler.

Additionally, we used keyup->typing#typingStopped in the view to call the typingStopped function. Here, we debounce the typingStopped function to only call it once every 1000 milliseconds or one second. This function will prevent the typingStopped method from being called too frequently and basically allow the controller to wait for the typing to be stopped. Using a debounce function is a common pattern in JavaScript to prevent a function from being called too frequently. We are using debounce from the lodash library to do this and was added in the import statement at the top of the file. The trick to getting the StimulusJS-based keydown event to use the lodash debounce is to set the controller's typingStopped function in the initializer to itself inside the debounce function call.

The typingStopped function then calls the perform method on the subscription to broadcast the typing_stopped event to the TypingChannel much like the same round trip for typing.

To get the controller ready to display text, we added data-typing-target="display" in the view to display the typing indicator and then added a static targets = ["display"]; to allow this controller to interact with that element.

Now, finally getting back to the code inside the received handler, we check if the uid in the data object does not equal the userId from the meta tag. If the uid is not equal to the userId, then we update the displayTarget with the user's email and a message that they are typing. Inside the innerHTML setter, we use a ternary operator to check if the action in the data object equals typing. If it is, we set the innerHTML to the user's email and a message they are typing. If the action is not equal to typing, then we set the innerHTML to an empty string which clears the message in the browser.

Now, suppose you were to open two browsers (again, a current browser and an incognito would work!) and log into each user. In that case, you would see the typing indicator change in real time as one user types; the other would see the indicator on their browser!

Conclusion

Anyway, closing out these tutorials always feel weird. Hope you learned something from this and leave a comment with any questions or feedback!

đź’– đź’Ş đź™… đźš©
rob__race
Rob Race

Posted on October 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related