Modern Rails flash messages (part 3): Refactoring with Turbo

I can not believe it has been almost 10 months since I wrote the first part. Several things have happened since then. Like Turbo...

The demo application still works in the same way, but a few changes needed to be made to support Turbo. I also refactored it a little bit to make it easier to use with multiple controllers.

I've updated the code at the repository on GitHub with the support for Turbo.

Installing Turbo

That was really easy, just run bundle add turbo-rails and run bin/rails turbo:install.

Don't forget to replace any mention of turbolinks to turbo (eg. data-turbolinks-track => data-turbo-track).

Usage with UJS and Turbo

The original solution worked with UJS and Turbolinks. Let's make it work in the same way but with Turbo. You can see mentioned changes here.

Thanks to Turbo, we can remove code related to Turbolinks cache (this.isPreview at app/javascript/controllers/notification_controller.js) and replace it with data-turbo-cache="false" at app/components/notification_component.html.erb.

We also need to replace Turbolinks.visit with window.Turbo.visit in app/javascript/controllers/notification_controller.js.

I also started using the dom_id helper to identify HTML parts related to each post.

The last thing I need to mention is adding data: { turbo: false } to each remote: true link to disable Turbo on them. After that, it should work as before with Turbolinks.

BTW you should check out mrujs as a modern replacement for UJS. Konnor Rogers did an amazing job there.

Refactoring and usage with Turbo streams

You can see mentioned changes here.

The main goal here is to refactor the JS response for the undo action at app/views/posts/destroy.js.erb and replace it with Turbo stream.

The first thing that it does is adding a .hidden class to hide the post. That is something that Turbo can't do by default. Luckily, someone clever wrote an article about how to add a custom action to turbo streams. With that, we can refactor it.

From this:

// app/views/posts/destroy.js.erb

// finding and hiding the record
document.getElementById('<%= dom_id(post) %>').classList.toggle('hidden');

// displaying the notification
<% flash.each do |type, data|  %>
document.getElementById('notifications').insertAdjacentHTML("afterBegin", "<%=j render(NotificationComponent.new(type: type, data: data)) %>");
<% end %>

To the same functionality with Turbo stream:

<!-- app/views/undo/destroy.turbo_stream.erb -->

<turbo-stream action="addclass" target="<%= dom_id(record) %>">
  <template>hidden</template>
</turbo-stream>

<% flash.each do |type, data| %>
  <%= turbo_stream.prepend('notifications', render(NotificationComponent.new(type: type, data: data))) %>
<% end %>

Now, we could use that. Let's move the response from posts_controller.rb and place it to app/controllers/concerns/destroy_with_undo_response.rb, so we can easily reuse it. You can see that I also refactored it and added the Turbo stream support.

# app/controllers/concerns/destroy_with_undo_response.rb

module DestroyWithUndoResponse
  extend ActiveSupport::Concern

  private

  def destroy_with_undo_response(record:, job_id:, redirect:)
    respond_to do |format|
      format.html do
        flash[:success] = undo_flash_message(klass: record.class, job_id: job_id)
        redirect_to redirect
      end
      format.turbo_stream do
        if params[:redirect]
          flash[:success] = undo_flash_message(klass: record.class, job_id: job_id)
          redirect_to redirect
        else
          flash.now[:success] = undo_flash_message(klass: record.class, job_id: job_id, inline: true)
          render 'undo/destroy', locals: { record: record }
        end
      end
    end
  end

  def undo_flash_message(klass:, job_id:, inline: nil)
    timeout = defined?(klass::UNDO_TIMEOUT) ? klass::UNDO_TIMEOUT : 10

    {
      title: "#{klass.model_name.human} was removed",
      body: 'You can recover it using the undo action below.',
      timeout: timeout, countdown: true,
      action: {
        url: undo_path(job_id, inline: inline),
        method: 'delete',
        name: 'Undo'
      }
    }
  end
end

We can now include it in the PostsController and update the destroy action:

class PostsController < ApplicationController
  include DestroyWithUndoResponse

  def destroy
    post = Post.active.find(params[:id])
    job_id = post.schedule_destroy
    destroy_with_undo_response(record: post, job_id: job_id, redirect: posts_path)
  end
end

In app/views/posts/show.html.erb and app/views/posts/_form.html.erb we need to add a param redirect: true to each post_path(@post). This param is then used in the turbo stream response to do the right action.

In app/views/posts/index.html.erb we can remove remote: true, data: { turbo: false } as we will leverage Turbo.

And that's all. The application now behaves exactly the same, but with Turbo.

22