turbo-rails 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +24 -0
  3. data/.gitignore +2 -0
  4. data/Gemfile +6 -0
  5. data/Gemfile.lock +147 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +66 -0
  8. data/Rakefile +11 -0
  9. data/app/assets/javascripts/turbo.js +3161 -0
  10. data/app/channels/turbo/streams/broadcasts.rb +66 -0
  11. data/app/channels/turbo/streams/stream_name.rb +24 -0
  12. data/app/channels/turbo/streams_channel.rb +17 -0
  13. data/app/controllers/turbo/frames/frame_request.rb +24 -0
  14. data/app/controllers/turbo/native/navigation.rb +49 -0
  15. data/app/controllers/turbo/native/navigation_controller.rb +13 -0
  16. data/app/controllers/turbo/streams/turbo_streams_tag_builder.rb +22 -0
  17. data/app/helpers/turbo/drive_helper.rb +16 -0
  18. data/app/helpers/turbo/frames_helper.rb +23 -0
  19. data/app/helpers/turbo/includes_helper.rb +5 -0
  20. data/app/helpers/turbo/streams/action_helper.rb +25 -0
  21. data/app/helpers/turbo/streams_helper.rb +22 -0
  22. data/app/javascript/turbo/cable.js +16 -0
  23. data/app/javascript/turbo/cable_stream_source_element.js +27 -0
  24. data/app/javascript/turbo/index.js +3 -0
  25. data/app/jobs/turbo/streams/action_broadcast_job.rb +6 -0
  26. data/app/jobs/turbo/streams/broadcast_job.rb +7 -0
  27. data/app/models/concerns/turbo/broadcastable.rb +236 -0
  28. data/app/models/turbo/streams/tag_builder.rb +127 -0
  29. data/config/routes.rb +6 -0
  30. data/lib/install/turbo.rb +11 -0
  31. data/lib/tasks/turbo_tasks.rake +6 -0
  32. data/lib/turbo-rails.rb +17 -0
  33. data/lib/turbo/engine.rb +65 -0
  34. data/lib/turbo/test_assertions.rb +22 -0
  35. data/lib/turbo/version.rb +3 -0
  36. data/package.json +42 -0
  37. data/rollup.config.js +23 -0
  38. data/test/drive/drive_helper_test.rb +8 -0
  39. data/test/dummy/.babelrc +18 -0
  40. data/test/dummy/.gitignore +3 -0
  41. data/test/dummy/.postcssrc.yml +3 -0
  42. data/test/dummy/Rakefile +6 -0
  43. data/test/dummy/app/assets/config/manifest.js +2 -0
  44. data/test/dummy/app/assets/images/.keep +0 -0
  45. data/test/dummy/app/assets/stylesheets/application.css +15 -0
  46. data/test/dummy/app/assets/stylesheets/scaffold.css +80 -0
  47. data/test/dummy/app/channels/application_cable/channel.rb +4 -0
  48. data/test/dummy/app/channels/application_cable/connection.rb +4 -0
  49. data/test/dummy/app/controllers/application_controller.rb +2 -0
  50. data/test/dummy/app/controllers/concerns/.keep +0 -0
  51. data/test/dummy/app/controllers/messages_controller.rb +12 -0
  52. data/test/dummy/app/controllers/trays_controller.rb +17 -0
  53. data/test/dummy/app/helpers/application_helper.rb +2 -0
  54. data/test/dummy/app/javascript/packs/application.js +0 -0
  55. data/test/dummy/app/jobs/application_job.rb +2 -0
  56. data/test/dummy/app/mailboxes/application_mailbox.rb +2 -0
  57. data/test/dummy/app/mailboxes/messages_mailbox.rb +4 -0
  58. data/test/dummy/app/mailers/application_mailer.rb +4 -0
  59. data/test/dummy/app/models/application_record.rb +3 -0
  60. data/test/dummy/app/models/concerns/.keep +0 -0
  61. data/test/dummy/app/models/message.rb +29 -0
  62. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  63. data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  64. data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  65. data/test/dummy/app/views/messages/_message.html.erb +1 -0
  66. data/test/dummy/app/views/messages/_message.turbo_stream.erb +1 -0
  67. data/test/dummy/app/views/messages/show.turbo_stream.erb +9 -0
  68. data/test/dummy/app/views/trays/index.html.erb +3 -0
  69. data/test/dummy/app/views/trays/show.html.erb +3 -0
  70. data/test/dummy/bin/bundle +3 -0
  71. data/test/dummy/bin/rails +4 -0
  72. data/test/dummy/bin/rake +4 -0
  73. data/test/dummy/bin/setup +36 -0
  74. data/test/dummy/bin/update +31 -0
  75. data/test/dummy/bin/yarn +11 -0
  76. data/test/dummy/config.ru +5 -0
  77. data/test/dummy/config/application.rb +22 -0
  78. data/test/dummy/config/boot.rb +5 -0
  79. data/test/dummy/config/cable.yml +10 -0
  80. data/test/dummy/config/environment.rb +5 -0
  81. data/test/dummy/config/environments/development.rb +34 -0
  82. data/test/dummy/config/environments/production.rb +96 -0
  83. data/test/dummy/config/environments/test.rb +38 -0
  84. data/test/dummy/config/initializers/application_controller_renderer.rb +8 -0
  85. data/test/dummy/config/initializers/assets.rb +14 -0
  86. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  87. data/test/dummy/config/initializers/content_security_policy.rb +22 -0
  88. data/test/dummy/config/initializers/cookies_serializer.rb +5 -0
  89. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  90. data/test/dummy/config/initializers/inflections.rb +16 -0
  91. data/test/dummy/config/initializers/mime_types.rb +4 -0
  92. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  93. data/test/dummy/config/locales/en.yml +33 -0
  94. data/test/dummy/config/puma.rb +34 -0
  95. data/test/dummy/config/routes.rb +4 -0
  96. data/test/dummy/config/spring.rb +6 -0
  97. data/test/dummy/config/webpack/development.js +3 -0
  98. data/test/dummy/config/webpack/environment.js +3 -0
  99. data/test/dummy/config/webpack/production.js +3 -0
  100. data/test/dummy/config/webpack/test.js +3 -0
  101. data/test/dummy/config/webpacker.yml +65 -0
  102. data/test/dummy/lib/assets/.keep +0 -0
  103. data/test/dummy/log/.keep +0 -0
  104. data/test/dummy/public/404.html +67 -0
  105. data/test/dummy/public/422.html +67 -0
  106. data/test/dummy/public/500.html +66 -0
  107. data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
  108. data/test/dummy/public/apple-touch-icon.png +0 -0
  109. data/test/dummy/public/favicon.ico +0 -0
  110. data/test/frames/frame_request_controller_test.rb +21 -0
  111. data/test/frames/frames_helper_test.rb +15 -0
  112. data/test/native/navigation_controller_test.rb +42 -0
  113. data/test/streams/broadcastable_test.rb +80 -0
  114. data/test/streams/streams_channel_test.rb +105 -0
  115. data/test/streams/streams_controller_test.rb +29 -0
  116. data/test/turbo_test.rb +10 -0
  117. data/turbo-rails.gemspec +16 -0
  118. data/yarn.lock +282 -0
  119. metadata +254 -0
@@ -0,0 +1,66 @@
1
+ # Provides the broadcast actions in synchronous and asynchrous form for the <tt>Turbo::StreamsChannel</tt>.
2
+ # See <tt>Turbo::Broadcastable</tt> for the user-facing API that invokes these methods with most of the paperwork filled out already.
3
+ #
4
+ # Can be used directly using something like <tt>Turbo::StreamsChannel.broadcast_remove_to :entries, target: 1</tt>.
5
+ module Turbo::Streams::Broadcasts
6
+ include Turbo::Streams::ActionHelper
7
+
8
+ def broadcast_remove_to(*streamables, target:)
9
+ broadcast_action_to *streamables, action: :remove, target: target
10
+ end
11
+
12
+ def broadcast_replace_to(*streamables, target:, **rendering)
13
+ broadcast_action_to *streamables, action: :replace, target: target, **rendering
14
+ end
15
+
16
+ def broadcast_append_to(*streamables, target:, **rendering)
17
+ broadcast_action_to *streamables, action: :append, target: target, **rendering
18
+ end
19
+
20
+ def broadcast_prepend_to(*streamables, target:, **rendering)
21
+ broadcast_action_to *streamables, action: :prepend, target: target, **rendering
22
+ end
23
+
24
+ def broadcast_action_to(*streamables, action:, target:, **rendering)
25
+ broadcast_stream_to *streamables, content: turbo_stream_action_tag(action, target: target, template:
26
+ rendering.delete(:content) || (rendering.any? ? render_format(:html, **rendering) : nil)
27
+ )
28
+ end
29
+
30
+
31
+ def broadcast_replace_later_to(*streamables, target:, **rendering)
32
+ broadcast_action_later_to *streamables, action: :replace, target: target, **rendering
33
+ end
34
+
35
+ def broadcast_append_later_to(*streamables, target:, **rendering)
36
+ broadcast_action_later_to *streamables, action: :append, target: target, **rendering
37
+ end
38
+
39
+ def broadcast_prepend_later_to(*streamables, target:, **rendering)
40
+ broadcast_action_later_to *streamables, action: :prepend, target: target, **rendering
41
+ end
42
+
43
+ def broadcast_action_later_to(*streamables, action:, target:, **rendering)
44
+ Turbo::Streams::ActionBroadcastJob.perform_later \
45
+ stream_name_from(streamables), action: action, target: target, **rendering
46
+ end
47
+
48
+
49
+ def broadcast_render_to(*streamables, **rendering)
50
+ broadcast_stream_to *streamables, content: render_format(:turbo_stream, **rendering)
51
+ end
52
+
53
+ def broadcast_render_later_to(*streamables, **rendering)
54
+ Turbo::Streams::BroadcastJob.perform_later stream_name_from(streamables), **rendering
55
+ end
56
+
57
+ def broadcast_stream_to(*streamables, content:)
58
+ ActionCable.server.broadcast stream_name_from(streamables), content
59
+ end
60
+
61
+
62
+ private
63
+ def render_format(format, **rendering)
64
+ ApplicationController.render(formats: [ format ], **rendering)
65
+ end
66
+ end
@@ -0,0 +1,24 @@
1
+ # Stream names are how we identify which updates should go to which users. All streams run over the same
2
+ # <tt>Turbo::StreamsChannel</tt>, but each with their own subscription. Since stream names are exposed directly to the user
3
+ # via the HTML stream subscription tags, we need to ensure that the name isn't tampered with, so the names are signed
4
+ # upon generation and verified upon receipt. All verification happens through the <tt>Turbo.signed_stream_verifier</tt>.
5
+ module Turbo::Streams::StreamName
6
+ # Used by <tt>Turbo::StreamsChannel</tt> to verify a signed stream name.
7
+ def verified_stream_name(signed_stream_name)
8
+ Turbo.signed_stream_verifier.verified signed_stream_name
9
+ end
10
+
11
+ # Used by <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt> to generate a signed stream name.
12
+ def signed_stream_name(streamables)
13
+ Turbo.signed_stream_verifier.generate stream_name_from(streamables)
14
+ end
15
+
16
+ private
17
+ def stream_name_from(streamables)
18
+ if streamables.is_a?(Array)
19
+ streamables.map { |streamable| stream_name_from(streamable) }.join(":")
20
+ else
21
+ streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # The streams channel delivers all the turbo-stream actions created (primarily) through <tt>Turbo::Broadcastable</tt>.
2
+ # A subscription to this channel is made for each individual stream that one wishes to listen for updates to.
3
+ # The subscription relies on being passed a <tt>signed_stream_name</tt> parameter generated by turning a set of streamables
4
+ # into signed stream name using <tt>Turbo::Streams::StreamName#signed_stream_name</tt>. This is automatically done
5
+ # using the view helper <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt>.
6
+ # If the signed stream name cannot be verified, the subscription is rejected.
7
+ class Turbo::StreamsChannel < ActionCable::Channel::Base
8
+ extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
9
+
10
+ def subscribed
11
+ if verified_stream_name = self.class.verified_stream_name(params[:signed_stream_name])
12
+ stream_from verified_stream_name
13
+ else
14
+ reject
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # Turbo frame requests are requests made from within a turbo frame with the intention of replacing the content of just
2
+ # that frame, not the whole page. They are automatically tagged as such by the Turbo Frame JavaScript, which adds a
3
+ # <tt>Turbo-Frame</tt> header to the request. When that header is detected by the controller, we ensure that any
4
+ # template layout is skipped (since we're only working on an in-page frame, thus can skip the weight of the layout), and
5
+ # that the etag for the page is changed (such that a cache for a layout-less request isn't served on a normal request
6
+ # and vice versa).
7
+ #
8
+ # This is merely a rendering optimization. Everything would still work just fine if we rendered everything including the layout.
9
+ # Turbo Frames knows how to fish out the relevant frame regardless.
10
+ #
11
+ # This module is automatically included in <tt>ActionController::Base</tt>.
12
+ module Turbo::Frames::FrameRequest
13
+ extend ActiveSupport::Concern
14
+
15
+ included do
16
+ layout -> { false if turbo_frame_request? }
17
+ etag { :frame if turbo_frame_request? }
18
+ end
19
+
20
+ private
21
+ def turbo_frame_request?
22
+ request.headers["Turbo-Frame"].present?
23
+ end
24
+ end
@@ -0,0 +1,49 @@
1
+ # Turbo is built to work with native navigation principles and present those alongside what's required for the web. When you
2
+ # have Turbo Native clients running (see the Turbo iOS and Turbo Android projects for details), you can respond to native
3
+ # requests with three dedicated responses: <tt>recede</tt>, <tt>resume</tt>, <tt>refresh</tt>.
4
+ #
5
+ # FIXME: Supply full description of when we use either.
6
+ module Turbo::Native::Navigation
7
+ private
8
+
9
+ def recede_or_redirect_to(url, **options)
10
+ turbo_native_action_or_redirect url, :recede, :to, options
11
+ end
12
+
13
+ def resume_or_redirect_to(url, **options)
14
+ turbo_native_action_or_redirect url, :resume, :to, options
15
+ end
16
+
17
+ def refresh_or_redirect_to(url, **options)
18
+ turbo_native_action_or_redirect url, :refresh, :to, options
19
+ end
20
+
21
+
22
+ def recede_or_redirect_back_or_to(url, **options)
23
+ turbo_native_action_or_redirect url, :recede, :back, options
24
+ end
25
+
26
+ def resume_or_redirect_back_or_to(url, **options)
27
+ turbo_native_action_or_redirect url, :resume, :back, options
28
+ end
29
+
30
+ def refresh_or_redirect_back_or_to(url, **options)
31
+ turbo_native_action_or_redirect url, :refresh, :back, options
32
+ end
33
+
34
+ # :nodoc:
35
+ def turbo_native_action_or_redirect(url, action, redirect_type, options = {})
36
+ if turbo_native_app?
37
+ redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice] || options.delete(:native_notice))
38
+ elsif redirect_type == :back
39
+ redirect_back fallback_location: url, **options
40
+ else
41
+ redirect_to url, options
42
+ end
43
+ end
44
+
45
+ # Turbo Native applications are identified by having the string "Turbo Native" as part of their user agent.
46
+ def turbo_native_app?
47
+ request.user_agent.to_s.match?(/Turbo Native/)
48
+ end
49
+ end
@@ -0,0 +1,13 @@
1
+ class Turbo::Native::NavigationController < ActionController::Base
2
+ def recede
3
+ render html: "Going back…"
4
+ end
5
+
6
+ def refresh
7
+ render html: "Refreshing…"
8
+ end
9
+
10
+ def resume
11
+ render html: "Staying put…"
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ # Most turbo streams are rendered either asynchronously via <tt>Turbo::Broadcastable</tt>/<tt>Turbo::StreamsChannel</tt> or
2
+ # rendered in templates with the <tt>turbo_stream.erb</tt> extension. But it's also possible to render updates inline
3
+ # in controllers, like so:
4
+ #
5
+ # def destroy
6
+ # @user.destroy!
7
+ #
8
+ # respond_to do |format|
9
+ # format.turbo_stream { render turbo_stream: turbo_stream.remove(@user) }
10
+ # format.html { redirect_to users_url, notice: "User removed" }
11
+ # end
12
+ # end
13
+ #
14
+ # This module adds that turbo_stream tag-builder object to all controllers. It's an instance of <tt>Turbo::Streams::TagBuilder</tt>
15
+ # instantiated with the current <tt>view_context</tt>.
16
+ module Turbo::Streams::TurboStreamsTagBuilder
17
+ private
18
+
19
+ def turbo_stream
20
+ Turbo::Streams::TagBuilder.new(view_context)
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ module Turbo::DriveHelper
2
+ # Pages that are more likely than not to be a cache miss can skip turbo cache to avoid visual jitter.
3
+ # Note: This requires a +yield :head+ provision in the application layout.
4
+ #
5
+ # ==== Example
6
+ #
7
+ # # app/views/application.html.erb
8
+ # <html><head><%= yield :head %></head><body><%= yield %></html>
9
+ #
10
+ # # app/views/trays/index.html.erb
11
+ # <% turbo_exempts_page_from_cache %>
12
+ # <p>Page that shouldn't be cached by Turbo</p>
13
+ def turbo_exempts_page_from_cache
14
+ provide :head, %(<meta name="turbo-cache-control" content="no-cache">).html_safe
15
+ end
16
+ end
@@ -0,0 +1,23 @@
1
+ module Turbo::FramesHelper
2
+ # Returns a frame tag that can either be used simply to encapsulate frame content or as a lazy-loading container that starts empty but
3
+ # fetches the URL supplied in the +src+ attribute.
4
+ #
5
+ # === Examples
6
+ #
7
+ # # => turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
8
+ # <%= turbo_frame_tag "tray", src: tray_path(tray) %>
9
+ #
10
+ # # => turbo-frame id="tray" links-target="top" src="http://example.com/trays/1"></turbo-frame>
11
+ # <%= turbo_frame_tag "tray", src: tray_path(tray), links_target: "top" %>
12
+ #
13
+ # # => turbo-frame id="tray" links-target="other_tray"></turbo-frame>
14
+ # <%= turbo_frame_tag "tray", links_target: "other_tray" %>
15
+ #
16
+ # # => turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
17
+ # <%= turbo_frame_tag "tray" do %>
18
+ # <div>My tray frame!</div>
19
+ # <% end %>
20
+ def turbo_frame_tag(id, src: nil, target: nil, **attributes, &block)
21
+ tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block)
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Turbo::IncludesHelper
2
+ def turbo_include_tags
3
+ javascript_include_tag("turbo", type: "module")
4
+ end
5
+ end
@@ -0,0 +1,25 @@
1
+ module Turbo::Streams::ActionHelper
2
+ # Creates a `turbo-stream` tag according to the passed parameters. Examples:
3
+ #
4
+ # # => <turbo-stream action="remove" target="message_1"></turbo-stream>
5
+ # turbo_stream_action_tag "remove", target: "message_1"
6
+ #
7
+ # # => <turbo-stream action="replace" target="message_1"><template><div id="message_1">Hello!</div></template></turbo-stream>
8
+ # turbo_stream_action_tag "replace", target: "message_1", template: %(<div id="message_1">Hello!</div>)
9
+ def turbo_stream_action_tag(action, target:, template: nil)
10
+ target = convert_to_turbo_stream_dom_id(target)
11
+ template = template ? "<template>#{template}</template>" : ""
12
+
13
+ %(<turbo-stream action="#{action}" target="#{target}">#{template}</turbo-stream>).html_safe
14
+ end
15
+
16
+ private
17
+ def convert_to_turbo_stream_dom_id(element_or_dom_id)
18
+ if element_or_dom_id.respond_to?(:to_key)
19
+ element = element_or_dom_id
20
+ ActionView::RecordIdentifier.dom_id(element)
21
+ else
22
+ dom_id = element_or_dom_id
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ module Turbo::StreamsHelper
2
+ # Returns a new <tt>Turbo::Streams::TagBuilder</tt> object that accepts stream actions and renders them them as
3
+ # the template tags needed to send across the wire. This object is automatically yielded to turbo_stream.erb templates.
4
+ def turbo_stream
5
+ Turbo::Streams::TagBuilder.new(self)
6
+ end
7
+
8
+ # Used in the view to create a subscription to a stream identified by the <tt>streamables</tt> running over the
9
+ # <tt>Turbo::StreamsChannel</tt>. The stream name being generated is safe to embed in the HTML sent to a user without
10
+ # fear of tampering, as it is signed using <tt>Turbo.signed_stream_verifier</tt>. Example:
11
+ #
12
+ # # app/views/entries/index.html.erb
13
+ # <%= turbo_stream_from Current.account, :entries %>
14
+ # <div id="entries">New entries will be appended to this container</div>
15
+ #
16
+ # The example above will process all turbo streams sent to a stream name like <tt>account:5:entries</tt>
17
+ # (when Current.account.id = 5). Updates to this stream can be sent like
18
+ # <tt>entry.broadcast_append_to entry.account, :entries, contrainer: "entries"</tt>.
19
+ def turbo_stream_from(*streamables)
20
+ tag.turbo_cable_stream_source channel: "Turbo::StreamsChannel", "signed-stream-name": Turbo::StreamsChannel.signed_stream_name(streamables)
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ let consumer
2
+
3
+ export async function getConsumer() {
4
+ if (consumer) return consumer
5
+ const { createConsumer } = await import("@rails/actioncable/src")
6
+ return setConsumer(createConsumer())
7
+ }
8
+
9
+ export function setConsumer(newConsumer) {
10
+ return consumer = newConsumer
11
+ }
12
+
13
+ export async function subscribeTo(channel, mixin) {
14
+ const { subscriptions } = await getConsumer()
15
+ return subscriptions.create(channel, mixin)
16
+ }
@@ -0,0 +1,27 @@
1
+ import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
2
+ import { subscribeTo } from "./cable"
3
+
4
+ class TurboCableStreamSourceElement extends HTMLElement {
5
+ async connectedCallback() {
6
+ connectStreamSource(this)
7
+ this.subscription = subscribeTo(this.channel, { received: this.dispatchMessageEvent.bind(this) })
8
+ }
9
+
10
+ disconnectedCallback() {
11
+ disconnectStreamSource(this)
12
+ if (this.subscription) this.subscription.unsubscribe()
13
+ }
14
+
15
+ dispatchMessageEvent(data) {
16
+ const event = new MessageEvent("message", { data })
17
+ return this.dispatchEvent(event)
18
+ }
19
+
20
+ get channel() {
21
+ const channel = this.getAttribute("channel")
22
+ const signed_stream_name = this.getAttribute("signed-stream-name")
23
+ return { channel, signed_stream_name }
24
+ }
25
+ }
26
+
27
+ customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
@@ -0,0 +1,3 @@
1
+ import "./cable_stream_source_element"
2
+ export * as Turbo from "@hotwired/turbo"
3
+ export * as cable from "./cable"
@@ -0,0 +1,6 @@
1
+ # The job that powers all the <tt>broadcast_$action_later</tt> broadcasts available in <tt>Turbo::Streams::Broadcasts</tt>.
2
+ class Turbo::Streams::ActionBroadcastJob < ActiveJob::Base
3
+ def perform(stream, action:, target:, **rendering)
4
+ Turbo::StreamsChannel.broadcast_action_to stream, action: action, target: target, **rendering
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ # The job that powers the <tt>broadcast_render_later_to</tt> available in <tt>Turbo::Streams::Broadcasts</tt> for rendering
2
+ # turbo stream templates.
3
+ class Turbo::Streams::BroadcastJob < ActiveJob::Base
4
+ def perform(stream, **rendering)
5
+ Turbo::StreamsChannel.broadcast_render_to stream, **rendering
6
+ end
7
+ end
@@ -0,0 +1,236 @@
1
+ # Turbo streams can be broadcast directly from models that include this module (this is automatically done for Active Records).
2
+ # This makes it convenient to execute both synchronous and asynchronous updates, and render directly from callbacks in models
3
+ # or from controllers or jobs that act on those models. Here's an example:
4
+ #
5
+ # class Clearance < ApplicationRecord
6
+ # belongs_to :petitioner, class_name: "Contact"
7
+ # belongs_to :examiner, class_name: "User"
8
+ #
9
+ # after_create_commit :broadcast_later
10
+ #
11
+ # private
12
+ # def broadcast_later
13
+ # broadcast_prepend_later_to examiner.identity, :clearances
14
+ # end
15
+ # end
16
+ #
17
+ # This is an example from [HEY](https://hey.com), and the clearance is the model that drives
18
+ # [the screener](https://hey.com/features/the-screener/), which gives users the power to deny first-time senders (petitioners)
19
+ # access to their attention (as the examiner). When a new clearance is created upon receipt of an email from a first-time
20
+ # sender, that'll trigger the call to broadcast_later, which in turn invokes <tt>broadcast_prepend_later_to</tt>.
21
+ #
22
+ # That method enqueues a <tt>Turbo::Streams::ActionBroadcastJob</tt> for the prepend, which will render the partial for clearance
23
+ # (it knows which by calling Clearance#to_partial_path, which in this case returns <tt>clearances/_clearance.html.erb</tt>),
24
+ # send that to all users that have subscribed to updates (using <tt>turbo_stream_from(examiner.identity, :clearances)</tt> in a view)
25
+ # using the <tt>Turbo::StreamsChannel</tt> under the stream name derived from <tt>[ examiner.identity, :clearances ]</tt>,
26
+ # and finally prepend the result of that partial rendering to the target identified with the dom id "clearances"
27
+ # (which is derived by default from the plural model name of the model, but can be overwritten).
28
+ #
29
+ # There are four basic actions you can broadcast: <tt>remove</tt>, <tt>replace</tt>, <tt>append</tt>, and
30
+ # <tt>prepend</tt>. As a rule, you should use the <tt>_later</tt> versions of everything except for remove when broadcasting
31
+ # within a real-time path, like a controller or model, since all those updates require a rendering step, which can slow down
32
+ # execution. You don't need to do this for remove, since only the dom id for the model is used.
33
+ #
34
+ # In addition to the four basic actions, you can also use <tt>broadcast_render_later</tt> or
35
+ # <tt>broadcast_render_later_to</tt> to render a turbo stream template with multiple actions.
36
+ module Turbo::Broadcastable
37
+ extend ActiveSupport::Concern
38
+
39
+ module ClassMethods
40
+ # Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
41
+ # <tt>stream</tt> symbol invocation. By default, the creates are appended to a dom id target name derived from
42
+ # the model's plural name. The insertion can also be made to be a prepend by overwriting <tt>insertion</tt> and
43
+ # the target dom id overwritten by passing <tt>target</tt>. Examples:
44
+ #
45
+ # class Message < ApplicationRecord
46
+ # belongs_to :board
47
+ # broadcasts_to :board
48
+ # end
49
+ #
50
+ # class Message < ApplicationRecord
51
+ # belongs_to :board
52
+ # broadcasts_to ->(message) { [ message.board, :messages ] }, inserts_by: :prepend, target: "board_messages"
53
+ # end
54
+ def broadcasts_to(stream, inserts_by: :append, target: model_name.plural)
55
+ after_create_commit -> { broadcast_action_later_to stream.try(:call, self) || send(stream), action: inserts_by, target: target }
56
+ after_update_commit -> { broadcast_replace_later_to stream.try(:call, self) || send(stream) }
57
+ after_destroy_commit -> { broadcast_remove_to stream.try(:call, self) || send(stream) }
58
+ end
59
+
60
+ # Same as <tt>#broadcasts_to</tt>, but the designated stream is automatically set to the current model.
61
+ def broadcasts(inserts_by: :append, target: model_name.plural)
62
+ after_create_commit -> { broadcast_action_later action: inserts_by, target: target }
63
+ after_update_commit -> { broadcast_replace_later }
64
+ after_destroy_commit -> { broadcast_remove }
65
+ end
66
+ end
67
+
68
+ # Remove this broadcastable model from the dom for subscribers of the stream name identified by the passed streamables.
69
+ # Example:
70
+ #
71
+ # # Sends <turbo-stream action="remove" target="clearance_5"></turbo-stream> to the stream named "identity:2:clearances"
72
+ # clearance.broadcast_remove_to examiner.identity, :clearances
73
+ def broadcast_remove_to(*streamables)
74
+ Turbo::StreamsChannel.broadcast_remove_to *streamables, target: self
75
+ end
76
+
77
+ # Same as <tt>#broadcast_remove_to</tt>, but the designated stream is automatically set to the current model.
78
+ def broadcast_remove
79
+ broadcast_remove_to self
80
+ end
81
+
82
+ # Replace this broadcastable model in the dom for subscribers of the stream name identified by the passed
83
+ # <tt>streamables</tt>. The rendering parameters can be set by appending named arguments to the call. Examples:
84
+ #
85
+ # # Sends <turbo-stream action="replace" target="clearance_5"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
86
+ # # to the stream named "identity:2:clearances"
87
+ # clearance.broadcast_replace_to examiner.identity, :clearances
88
+ #
89
+ # # Sends <turbo-stream action="replace" target="clearance_5"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
90
+ # # to the stream named "identity:2:clearances"
91
+ # clearance.broadcast_replace_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
92
+ def broadcast_replace_to(*streamables, **rendering)
93
+ Turbo::StreamsChannel.broadcast_replace_to *streamables, target: self, **broadcast_rendering_with_defaults(rendering)
94
+ end
95
+
96
+ # Same as <tt>#broadcast_replace_to</tt>, but the designated stream is automatically set to the current model.
97
+ def broadcast_replace(**rendering)
98
+ broadcast_replace_to self, **rendering
99
+ end
100
+
101
+ # Append a rendering of this broadcastable model to the target identified by it's dom id passed as <tt>target</tt>
102
+ # for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
103
+ # appending named arguments to the call. Examples:
104
+ #
105
+ # # Sends <turbo-stream action="append" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
106
+ # # to the stream named "identity:2:clearances"
107
+ # clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances"
108
+ #
109
+ # # Sends <turbo-stream action="append" target="clearances"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
110
+ # # to the stream named "identity:2:clearances"
111
+ # clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances",
112
+ # partial: "clearances/other_partial", locals: { a: 1 }
113
+ def broadcast_append_to(*streamables, target: broadcast_target_default, **rendering)
114
+ Turbo::StreamsChannel.broadcast_append_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
115
+ end
116
+
117
+ # Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
118
+ def broadcast_append(target: broadcast_target_default, **rendering)
119
+ broadcast_append_to self, target: target, **rendering
120
+ end
121
+
122
+ # Prepend a rendering of this broadcastable model to the target identified by it's dom id passed as <tt>target</tt>
123
+ # for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
124
+ # appending named arguments to the call. Examples:
125
+ #
126
+ # # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
127
+ # # to the stream named "identity:2:clearances"
128
+ # clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances"
129
+ #
130
+ # # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
131
+ # # to the stream named "identity:2:clearances"
132
+ # clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances",
133
+ # partial: "clearances/other_partial", locals: { a: 1 }
134
+ def broadcast_prepend_to(*streamables, target: broadcast_target_default, **rendering)
135
+ Turbo::StreamsChannel.broadcast_prepend_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
136
+ end
137
+
138
+ # Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
139
+ def broadcast_prepend(target: broadcast_target_default, **rendering)
140
+ broadcast_prepend_to self, target: target, **rendering
141
+ end
142
+
143
+ # Broadcast a named <tt>action</tt>, allowing for dynamic dispatch, instead of using the concrete action methods. Examples:
144
+ #
145
+ # # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
146
+ # # to the stream named "identity:2:clearances"
147
+ # clearance.broadcast_action_to examiner.identity, :clearances, action: :prepend, target: "clearances"
148
+ def broadcast_action_to(*streamables, action:, target: broadcast_target_default, **rendering)
149
+ Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, **broadcast_rendering_with_defaults(rendering))
150
+ end
151
+
152
+ # Same as <tt>#broadcast_action_to</tt>, but the designated stream is automatically set to the current model.
153
+ def broadcast_action(action, target: broadcast_target_default, **rendering)
154
+ broadcast_action_to self, action: action, target: target, **rendering
155
+ end
156
+
157
+
158
+ # Same as <tt>broadcast_replace_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
159
+ def broadcast_replace_later_to(*streamables, **rendering)
160
+ Turbo::StreamsChannel.broadcast_replace_later_to *streamables, target: self, **broadcast_rendering_with_defaults(rendering)
161
+ end
162
+
163
+ # Same as <tt>#broadcast_replace_later_to</tt>, but the designated stream is automatically set to the current model.
164
+ def broadcast_replace_later(**rendering)
165
+ broadcast_replace_later_to self, **rendering
166
+ end
167
+
168
+ # Same as <tt>broadcast_append_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
169
+ def broadcast_append_later_to(*streamables, target: broadcast_target_default, **rendering)
170
+ Turbo::StreamsChannel.broadcast_append_later_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
171
+ end
172
+
173
+ # Same as <tt>#broadcast_append_later_to</tt>, but the designated stream is automatically set to the current model.
174
+ def broadcast_append_later(target: broadcast_target_default, **rendering)
175
+ broadcast_append_later_to self, target: target, **rendering
176
+ end
177
+
178
+ # Same as <tt>broadcast_prepend_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
179
+ def broadcast_prepend_later_to(*streamables, target: broadcast_target_default, **rendering)
180
+ Turbo::StreamsChannel.broadcast_prepend_later_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
181
+ end
182
+
183
+ # Same as <tt>#broadcast_prepend_later_to</tt>, but the designated stream is automatically set to the current model.
184
+ def broadcast_prepend_later(target: broadcast_target_default, **rendering)
185
+ broadcast_prepend_later_to self, target: target, **rendering
186
+ end
187
+
188
+ # Same as <tt>broadcast_action_later_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
189
+ def broadcast_action_later_to(*streamables, action:, target: broadcast_target_default, **rendering)
190
+ Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, **broadcast_rendering_with_defaults(rendering))
191
+ end
192
+
193
+ # Same as <tt>#broadcast_action_later_to</tt>, but the designated stream is automatically set to the current model.
194
+ def broadcast_action_later(action:, target: broadcast_target_default, **rendering)
195
+ broadcast_action_later_to self, action: action, target: target, **rendering
196
+ end
197
+
198
+
199
+ # Render a turbo stream template asynchronously with this broadcastable model passed as the local variable using a
200
+ # <tt>Turbo::Streams::BroadcastJob</tt>. Example:
201
+ #
202
+ # # Template: entries/_entry.turbo_stream.erb
203
+ # <%= turbo_stream.remove entry %>
204
+ #
205
+ # <%= turbo_stream.append "entries" do %>
206
+ # <%= render partial: "entries/entry", locals: { entry: entry }, formats: [ :html ] %>
207
+ # <% end if entry.active? %>
208
+ #
209
+ # # Sends:
210
+ # # <turbo-stream action="remove" target="entry_5"></turbo-stream>
211
+ # # <turbo-stream action="append" target="entries"><template><div id="entry_5">My Entry</div></template></turbo-stream>
212
+ # # to the stream named "entry:5"
213
+ # entry.broadcast_render_later
214
+ def broadcast_render_later(**rendering)
215
+ broadcast_render_later_to self, **rendering
216
+ end
217
+
218
+ # Same as <tt>broadcast_prepend_to</tt> but run with the added option of naming the stream using the passed
219
+ # <tt>streamables</tt>.
220
+ def broadcast_render_later_to(*streamables, **rendering)
221
+ Turbo::StreamsChannel.broadcast_render_later_to *streamables, **broadcast_rendering_with_defaults(rendering)
222
+ end
223
+
224
+
225
+ private
226
+ def broadcast_target_default
227
+ model_name.plural
228
+ end
229
+
230
+ def broadcast_rendering_with_defaults(options)
231
+ options.tap do |o|
232
+ o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.singular.to_sym => self)
233
+ o[:partial] ||= to_partial_path
234
+ end
235
+ end
236
+ end