hotwired / turbo-rails

Use Turbo in your Ruby on Rails app
https://turbo.hotwired.dev
MIT License
2.08k stars 322 forks source link

Enhancement Request: Directly Add Attributes to turbo-stream Tag without Partials in Broadcastable Methods #486

Open brydave opened 1 year ago

brydave commented 1 year ago

As far as I can tell, and I could totally be missing something, it seems like there's no direct way to send attributes to a turbo-stream tag via a broadcastable method without using a partial or HTML content. Here's a scenario:

Imagine importing data in a background job. You want to display a loading page and then redirect when the import is complete using a custom turbo action.

To achieve this currently, I think I have to:

  1. Create an HTML partial with the attributes.
  2. Stream the partial.
  3. Then, search for these attributes in a custom turbo-stream action.

This should work, but a more streamlined approach might be to add HTML attributes directly to the broadcasted tag without having to send a partial. It might also integrate nicely with custom turbo stream actions.

To get this working, I created a custom broadcast_custom_action_to method, which allows attributes to be applied directly on the turbo_stream_action_tag and has a default turbo_stream rendering. But I'm not sure if this is the best practice.

Is there a more efficient or recommended way to enhance broadcastable methods?


Here's an example of the existing implementation:

class ImportJob < ApplicationJob
  def perform(import)
    # process import
    @import.broadcast_action_to "loading-state", action: :redirect_to, partial: "loading", locals: { url: my_redirect_url, turbo_action: "replace" }
  end
end
<!-- _loading.turbo_stream.erb -->
<turbo-stream action="redirect_to">
  <template>
    <div url="<%= url %>" turbo-action="<%= turbo_action %>"></div>
  </template>
</turbo-stream>
// custom turbo-stream action
import { StreamActions } from "@hotwired/turbo"

StreamActions.redirect_to = function() {
  const content = this.querySelector("template").content
  const url = content.querySelector("div[url]").getAttribute("url")
  const turboAction = content.querySelector("div[turbo-action]").getAttribute("turbo-action")
  Turbo.visit(url, { action: turboAction })
}

And here's an example of the broadcast_custom_action_to method that doesn't require an arbitrary partial, and applies the attributes to the turbo-stream tag itself.

class ImportJob < ApplicationJob
  def perform
    # process import
    @import.broadcast_custom_action_to "loading-state", action: :redirect_to, attributes: { url: import_finished_url(@import), turbo_action: "replace" }
  end
end
// custom turbo-stream action
import { StreamActions } from "@hotwired/turbo"

StreamActions.redirect_to = function() {
  const url = this.getAttribute("url")
  const turboAction = this.getAttribute("turbo-action")
  Turbo.visit(url, { action: turboAction })
}

To implement the custom action, here's how I expanded the broadcasts and broadcastable modules:

module Turbo::Streams::Broadcasts
  def broadcast_custom_action_to(*streamables, action:, target: nil, targets: nil, content: nil, attributes: nil, **rendering)
    broadcast_stream_to(*streamables, content:
      turbo_stream_action_tag(action, target: target, targets: targets,
        template: rendering.delete(:content) || rendering.delete(:html) || (rendering.any? ? render_format(:html, **rendering) : nil),
        **attributes
      )
    )
  end
end

module Turbo::Broadcastable
  def broadcast_custom_action_to(*streamables, action:, target: nil, targets: nil, content: nil, attributes: nil, **rendering)
    Turbo::StreamsChannel.broadcast_custom_action_to(streamables, action: action, target: target, targets: targets, content: content, attributes: attributes, **rendering)
  end
end

Hopefully that makes sense, but I'm happy to clarify anything!

brydave commented 1 year ago

After further exploration, I'll also add that the syntax for a more specific,broadcast_redirect_to method, seems nice and concise. But, I'd love to hear if anyone has any other recommendations.

class ImportJob < ApplicationJob
  def perform(import)
    # process import
    @import.broadcast_redirect_to "loading-state", url: my_redirect_url, turbo_action: "replace"
  end
end
// same JavaScript
import { StreamActions } from "@hotwired/turbo"

StreamActions.redirect_to = function() {
  const content = this.querySelector("template").content
  const url = content.querySelector("div[url]").getAttribute("url")
  const turboAction = content.querySelector("div[turbo-action]").getAttribute("turbo-action")
  Turbo.visit(url, { action: turboAction })
}

Here are the brodcastable modules I added to an initializer to get it working:

module Turbo::Streams::Broadcasts
   def broadcast_redirect_to(*streamables, url:, turbo_action:)
     broadcast_stream_to(streamables, content: turbo_stream_action_tag(:redirect_to, url: url, "turbo-action": turbo_action))
   end
 end

 module Turbo::Broadcastable
   def broadcast_redirect_to(*streamables, url:, turbo_action: "advance")
     Turbo::StreamsChannel.broadcast_redirect_to(streamables, url: url, turbo_action: turbo_action)
   end
 end