turbo-rails 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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