turbo-rails 1.3.0 → 1.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.
@@ -1,20 +1,30 @@
1
1
  # Turbo frame requests are requests made from within a turbo frame with the intention of replacing the content of just
2
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).
3
+ # <tt>Turbo-Frame</tt> header to the request.
7
4
  #
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.
5
+ # When that header is detected by the controller, we substitute our own minimal layout in place of the
6
+ # application-supplied layout (since we're only working on an in-page frame, thus can skip the weight of the layout). We
7
+ # use a minimal layout, rather than avoid the layout entirely, so that it's still possible to render content into the
8
+ # <tt>head<tt>.
9
+ #
10
+ # Accordingly, we ensure that the etag for the page is changed, such that a cache for a minimal-layout request isn't
11
+ # served on a normal request and vice versa.
12
+ #
13
+ # This is merely a rendering optimization. Everything would still work just fine if we rendered everything including the
14
+ # full layout. Turbo Frames knows how to fish out the relevant frame regardless.
15
+ #
16
+ # The layout used is <tt>turbo_rails/frame.html.erb</tt>. If there's a need to customize this layout, an application can
17
+ # supply its own (such as <tt>app/views/layouts/turbo_rails/frame.html.erb</tt>) which will be used instead.
10
18
  #
11
19
  # This module is automatically included in <tt>ActionController::Base</tt>.
12
20
  module Turbo::Frames::FrameRequest
13
21
  extend ActiveSupport::Concern
14
22
 
15
23
  included do
16
- layout -> { false if turbo_frame_request? }
24
+ layout -> { "turbo_rails/frame" if turbo_frame_request? }
17
25
  etag { :frame if turbo_frame_request? }
26
+
27
+ helper_method :turbo_frame_request_id
18
28
  end
19
29
 
20
30
  private
@@ -2,23 +2,34 @@
2
2
  # have Turbo Native clients running (see the Turbo iOS and Turbo Android projects for details), you can respond to native
3
3
  # requests with three dedicated responses: <tt>recede</tt>, <tt>resume</tt>, <tt>refresh</tt>.
4
4
  #
5
- # FIXME: Supply full description of when we use either.
5
+ # turbo-android handles these actions automatically. You are required to implement the handling on your own for turbo-ios.
6
6
  module Turbo::Native::Navigation
7
- private
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ helper_method :turbo_native_app?
11
+ end
8
12
 
13
+ # Turbo Native applications are identified by having the string "Turbo Native" as part of their user agent.
14
+ def turbo_native_app?
15
+ request.user_agent.to_s.match?(/Turbo Native/)
16
+ end
17
+
18
+ # Tell the Turbo Native app to dismiss a modal (if presented) or pop a screen off of the navigation stack.
9
19
  def recede_or_redirect_to(url, **options)
10
20
  turbo_native_action_or_redirect url, :recede, :to, options
11
21
  end
12
22
 
23
+ # Tell the Turbo Native app to ignore this navigation.
13
24
  def resume_or_redirect_to(url, **options)
14
25
  turbo_native_action_or_redirect url, :resume, :to, options
15
26
  end
16
27
 
28
+ # Tell the Turbo Native app to refresh the current screen.
17
29
  def refresh_or_redirect_to(url, **options)
18
30
  turbo_native_action_or_redirect url, :refresh, :to, options
19
31
  end
20
32
 
21
-
22
33
  def recede_or_redirect_back_or_to(url, **options)
23
34
  turbo_native_action_or_redirect url, :recede, :back, options
24
35
  end
@@ -30,20 +41,19 @@ module Turbo::Native::Navigation
30
41
  def refresh_or_redirect_back_or_to(url, **options)
31
42
  turbo_native_action_or_redirect url, :refresh, :back, options
32
43
  end
44
+
45
+ private
33
46
 
34
47
  # :nodoc:
35
48
  def turbo_native_action_or_redirect(url, action, redirect_type, options = {})
49
+ native_params = options.delete(:native_params) || {}
50
+
36
51
  if turbo_native_app?
37
- redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice] || options.delete(:native_notice))
52
+ redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice], **native_params)
38
53
  elsif redirect_type == :back
39
54
  redirect_back fallback_location: url, **options
40
55
  else
41
56
  redirect_to url, options
42
57
  end
43
58
  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
59
  end
@@ -36,7 +36,7 @@ module Turbo::FramesHelper
36
36
  # <%= turbo_frame_tag(Article.find(1), Comment.new) %>
37
37
  # # => <turbo-frame id="article_1_new_comment"></turbo-frame>
38
38
  def turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)
39
- id = ids.map { |id| id.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(id) : id }.join("_")
39
+ id = ids.first.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(*ids) : ids.first
40
40
  src = url_for(src) if src.present?
41
41
 
42
42
  tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block)
@@ -11,22 +11,34 @@ module Turbo::Streams::ActionHelper
11
11
  #
12
12
  # turbo_stream_action_tag "replace", targets: "message_1", template: %(<div id="message_1">Hello!</div>)
13
13
  # # => <turbo-stream action="replace" targets="message_1"><template><div id="message_1">Hello!</div></template></turbo-stream>
14
+ #
15
+ # The `target:` keyword option will forward `ActionView::RecordIdentifier#dom_id`-compatible arguments to
16
+ # `ActionView::RecordIdentifier#dom_id`
17
+ #
18
+ # message = Message.find(1)
19
+ # turbo_stream_action_tag "remove", target: message
20
+ # # => <turbo-stream action="remove" target="message_1"></turbo-stream>
21
+ #
22
+ # message = Message.find(1)
23
+ # turbo_stream_action_tag "remove", target: [message, :special]
24
+ # # => <turbo-stream action="remove" target="special_message_1"></turbo-stream>
25
+ #
14
26
  def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **attributes)
15
27
  template = action.to_sym == :remove ? "" : tag.template(template.to_s.html_safe)
16
28
 
17
29
  if target = convert_to_turbo_stream_dom_id(target)
18
- tag.turbo_stream(template, **attributes.merge(action: action, target: target))
30
+ tag.turbo_stream(template, **attributes, action: action, target: target)
19
31
  elsif targets = convert_to_turbo_stream_dom_id(targets, include_selector: true)
20
- tag.turbo_stream(template, **attributes.merge(action: action, targets: targets))
32
+ tag.turbo_stream(template, **attributes, action: action, targets: targets)
21
33
  else
22
- tag.turbo_stream(template, **attributes.merge(action: action))
34
+ tag.turbo_stream(template, **attributes, action: action)
23
35
  end
24
36
  end
25
37
 
26
38
  private
27
39
  def convert_to_turbo_stream_dom_id(target, include_selector: false)
28
- if target.respond_to?(:to_key)
29
- [ ("#" if include_selector), ActionView::RecordIdentifier.dom_id(target) ].compact.join
40
+ if Array(target).any? { |value| value.respond_to?(:to_key) }
41
+ "#{"#" if include_selector}#{ActionView::RecordIdentifier.dom_id(*target)}"
30
42
  else
31
43
  target
32
44
  end
@@ -5,7 +5,11 @@ import snakeize from "./snakeize"
5
5
  class TurboCableStreamSourceElement extends HTMLElement {
6
6
  async connectedCallback() {
7
7
  connectStreamSource(this)
8
- this.subscription = await subscribeTo(this.channel, { received: this.dispatchMessageEvent.bind(this) })
8
+ this.subscription = await subscribeTo(this.channel, {
9
+ received: this.dispatchMessageEvent.bind(this),
10
+ connected: this.subscriptionConnected.bind(this),
11
+ disconnected: this.subscriptionDisconnected.bind(this)
12
+ })
9
13
  }
10
14
 
11
15
  disconnectedCallback() {
@@ -18,6 +22,14 @@ class TurboCableStreamSourceElement extends HTMLElement {
18
22
  return this.dispatchEvent(event)
19
23
  }
20
24
 
25
+ subscriptionConnected() {
26
+ this.setAttribute("connected", "")
27
+ }
28
+
29
+ subscriptionDisconnected() {
30
+ this.removeAttribute("connected")
31
+ }
32
+
21
33
  get channel() {
22
34
  const channel = this.getAttribute("channel")
23
35
  const signed_stream_name = this.getAttribute("signed-stream-name")
@@ -25,4 +37,7 @@ class TurboCableStreamSourceElement extends HTMLElement {
25
37
  }
26
38
  }
27
39
 
28
- customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
40
+
41
+ if (customElements.get("turbo-cable-stream-source") === undefined) {
42
+ customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
43
+ }
@@ -3,13 +3,14 @@ export function encodeMethodIntoRequestBody(event) {
3
3
  const { target: form, detail: { fetchOptions } } = event
4
4
 
5
5
  form.addEventListener("turbo:submit-start", ({ detail: { formSubmission: { submitter } } }) => {
6
- const method = (submitter && submitter.formMethod) || (fetchOptions.body && fetchOptions.body.get("_method")) || form.getAttribute("method")
6
+ const body = isBodyInit(fetchOptions.body) ? fetchOptions.body : new URLSearchParams()
7
+ const method = determineFetchMethod(submitter, body, form)
7
8
 
8
9
  if (!/get/i.test(method)) {
9
10
  if (/post/i.test(method)) {
10
- fetchOptions.body.delete("_method")
11
+ body.delete("_method")
11
12
  } else {
12
- fetchOptions.body.set("_method", method)
13
+ body.set("_method", method)
13
14
  }
14
15
 
15
16
  fetchOptions.method = "post"
@@ -17,3 +18,42 @@ export function encodeMethodIntoRequestBody(event) {
17
18
  }, { once: true })
18
19
  }
19
20
  }
21
+
22
+ function determineFetchMethod(submitter, body, form) {
23
+ const formMethod = determineFormMethod(submitter)
24
+ const overrideMethod = body.get("_method")
25
+ const method = form.getAttribute("method") || "get"
26
+
27
+ if (typeof formMethod == "string") {
28
+ return formMethod
29
+ } else if (typeof overrideMethod == "string") {
30
+ return overrideMethod
31
+ } else {
32
+ return method
33
+ }
34
+ }
35
+
36
+ function determineFormMethod(submitter) {
37
+ if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
38
+ // Rails 7 ActionView::Helpers::FormBuilder#button method has an override
39
+ // for formmethod if the button does not have name or value attributes
40
+ // set, which is the default. This means that if you use <%= f.button
41
+ // formmethod: :delete %>, it will generate a <button name="_method"
42
+ // value="delete" formmethod="post">. Therefore, if the submitter's name
43
+ // is already _method, it's value attribute already contains the desired
44
+ // method.
45
+ if (submitter.name === '_method') {
46
+ return submitter.value
47
+ } else if (submitter.hasAttribute("formmethod")) {
48
+ return submitter.formMethod
49
+ } else {
50
+ return null
51
+ }
52
+ } else {
53
+ return null
54
+ }
55
+ }
56
+
57
+ function isBodyInit(body) {
58
+ return body instanceof FormData || body instanceof URLSearchParams
59
+ }
@@ -27,8 +27,8 @@
27
27
  # (which is derived by default from the plural model name of the model, but can be overwritten).
28
28
  #
29
29
  # You can also choose to render html instead of a partial inside of a broadcast
30
- # you do this by passing the html: option to any broadcast method that accepts the **rendering argument
31
- #
30
+ # you do this by passing the `html:` option to any broadcast method that accepts the **rendering argument. Example:
31
+ #
32
32
  # class Message < ApplicationRecord
33
33
  # belongs_to :user
34
34
  #
@@ -39,7 +39,34 @@
39
39
  # broadcast_update_to(user, :messages, target: "message-count", html: "<p> #{user.messages.count} </p>")
40
40
  # end
41
41
  # end
42
- #
42
+ #
43
+ # If you want to render a template instead of a partial, e.g. ('messages/index' or 'messages/show'), you can use the `template:` option.
44
+ # Again, only to any broadcast method that accepts the `**rendering` argument. Example:
45
+ #
46
+ # class Message < ApplicationRecord
47
+ # belongs_to :user
48
+ #
49
+ # after_create_commit :update_message
50
+ #
51
+ # private
52
+ # def update_message
53
+ # broadcast_replace_to(user, :message, target: "message", template: "messages/show", locals: { message: self })
54
+ # end
55
+ # end
56
+ #
57
+ # If you want to render a renderable object you can use the `renderable:` option.
58
+ #
59
+ # class Message < ApplicationRecord
60
+ # belongs_to :user
61
+ #
62
+ # after_create_commit :update_message
63
+ #
64
+ # private
65
+ # def update_message
66
+ # broadcast_replace_to(user, :message, target: "message", renderable: MessageComponent.new)
67
+ # end
68
+ # end
69
+ #
43
70
  # There are four basic actions you can broadcast: <tt>remove</tt>, <tt>replace</tt>, <tt>append</tt>, and
44
71
  # <tt>prepend</tt>. As a rule, you should use the <tt>_later</tt> versions of everything except for remove when broadcasting
45
72
  # within a real-time path, like a controller or model, since all those updates require a rendering step, which can slow down
@@ -72,16 +99,16 @@ module Turbo::Broadcastable
72
99
  # broadcasts_to ->(message) { [ message.board, :messages ] }, partial: "messages/custom_message"
73
100
  # end
74
101
  def broadcasts_to(stream, inserts_by: :append, target: broadcast_target_default, **rendering)
75
- after_create_commit -> { broadcast_action_later_to stream.try(:call, self) || send(stream), action: inserts_by, target: target.try(:call, self) || target, **rendering }
76
- after_update_commit -> { broadcast_replace_later_to stream.try(:call, self) || send(stream), **rendering }
77
- after_destroy_commit -> { broadcast_remove_to stream.try(:call, self) || send(stream) }
102
+ after_create_commit -> { broadcast_action_later_to(stream.try(:call, self) || send(stream), action: inserts_by, target: target.try(:call, self) || target, **rendering) }
103
+ after_update_commit -> { broadcast_replace_later_to(stream.try(:call, self) || send(stream), **rendering) }
104
+ after_destroy_commit -> { broadcast_remove_to(stream.try(:call, self) || send(stream)) }
78
105
  end
79
106
 
80
107
  # Same as <tt>#broadcasts_to</tt>, but the designated stream for updates and destroys is automatically set to
81
108
  # the current model, for creates - to the model plural name, which can be overriden by passing <tt>stream</tt>.
82
109
  def broadcasts(stream = model_name.plural, inserts_by: :append, target: broadcast_target_default, **rendering)
83
- after_create_commit -> { broadcast_action_later_to stream, action: inserts_by, target: target.try(:call, self) || target, **rendering }
84
- after_update_commit -> { broadcast_replace_later **rendering }
110
+ after_create_commit -> { broadcast_action_later_to(stream, action: inserts_by, target: target.try(:call, self) || target, **rendering) }
111
+ after_update_commit -> { broadcast_replace_later(**rendering) }
85
112
  after_destroy_commit -> { broadcast_remove }
86
113
  end
87
114
 
@@ -335,8 +362,13 @@ module Turbo::Broadcastable
335
362
  # Add the current instance into the locals with the element name (which is the un-namespaced name)
336
363
  # as the key. This parallels how the ActionView::ObjectRenderer would create a local variable.
337
364
  o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.element.to_sym => self)
338
- # if the html option is passed in it will skip setting a partial from #to_partial_path
339
- unless o.include?(:html)
365
+
366
+ if o[:html] || o[:partial]
367
+ return o
368
+ elsif o[:template] || o[:renderable]
369
+ o[:layout] = false
370
+ else
371
+ # if none of these options are passed in, it will set a partial from #to_partial_path
340
372
  o[:partial] ||= to_partial_path
341
373
  end
342
374
  end
@@ -227,6 +227,8 @@ class Turbo::Streams::TagBuilder
227
227
  private
228
228
  def render_template(target, content = nil, allow_inferred_rendering: true, **rendering, &block)
229
229
  case
230
+ when content.respond_to?(:render_in)
231
+ content.render_in(@view_context, &block)
230
232
  when content
231
233
  allow_inferred_rendering ? (render_record(content) || content) : content
232
234
  when block_given?
@@ -0,0 +1,8 @@
1
+ <html>
2
+ <head>
3
+ <%= yield :head %>
4
+ </head>
5
+ <body>
6
+ <%= yield %>
7
+ </body>
8
+ </html>
data/config/routes.rb CHANGED
@@ -3,4 +3,4 @@ Rails.application.routes.draw do
3
3
  get "recede_historical_location" => "turbo/native/navigation#recede", as: :turbo_recede_historical_location
4
4
  get "resume_historical_location" => "turbo/native/navigation#resume", as: :turbo_resume_historical_location
5
5
  get "refresh_historical_location" => "turbo/native/navigation#refresh", as: :turbo_refresh_historical_location
6
- end
6
+ end if Turbo.draw_routes
@@ -0,0 +1,9 @@
1
+ if (js_entrypoint_path = Rails.root.join("app/javascript/application.js")).exist?
2
+ say "Import Turbo"
3
+ append_to_file "app/javascript/application.js", %(import "@hotwired/turbo-rails"\n)
4
+ else
5
+ say "You must import @hotwired/turbo-rails in your JavaScript entrypoint file", :red
6
+ end
7
+
8
+ say "Install Turbo"
9
+ run "bun add @hotwired/turbo-rails"
@@ -1,18 +1,27 @@
1
- def run_turbo_install_template(path)
2
- system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{File.expand_path("../install/#{path}.rb", __dir__)}"
3
- end
1
+ module Turbo
2
+ module Tasks
3
+ extend self
4
+ def run_turbo_install_template(path)
5
+ system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{File.expand_path("../install/#{path}.rb", __dir__)}"
6
+ end
4
7
 
5
- def redis_installed?
6
- Gem.win_platform? ?
7
- system('where redis-server > NUL 2>&1') :
8
- system('which redis-server > /dev/null')
9
- end
8
+ def redis_installed?
9
+ Gem.win_platform? ?
10
+ system('where redis-server > NUL 2>&1') :
11
+ system('which redis-server > /dev/null')
12
+ end
10
13
 
11
- def switch_on_redis_if_available
12
- if redis_installed?
13
- Rake::Task["turbo:install:redis"].invoke
14
- else
15
- puts "Run turbo:install:redis to switch on Redis and use it in development for turbo streams"
14
+ def switch_on_redis_if_available
15
+ if redis_installed?
16
+ Rake::Task["turbo:install:redis"].invoke
17
+ else
18
+ puts "Run turbo:install:redis to switch on Redis and use it in development for turbo streams"
19
+ end
20
+ end
21
+
22
+ def using_bun?
23
+ Rails.root.join("bun.config.js").exist?
24
+ end
16
25
  end
17
26
  end
18
27
 
@@ -21,6 +30,8 @@ namespace :turbo do
21
30
  task :install do
22
31
  if Rails.root.join("config/importmap.rb").exist?
23
32
  Rake::Task["turbo:install:importmap"].invoke
33
+ elsif Rails.root.join("package.json").exist? && Turbo::Tasks.using_bun?
34
+ Rake::Task["turbo:install:bun"].invoke
24
35
  elsif Rails.root.join("package.json").exist?
25
36
  Rake::Task["turbo:install:node"].invoke
26
37
  else
@@ -31,19 +42,25 @@ namespace :turbo do
31
42
  namespace :install do
32
43
  desc "Install Turbo into the app with asset pipeline"
33
44
  task :importmap do
34
- run_turbo_install_template "turbo_with_importmap"
35
- switch_on_redis_if_available
45
+ Turbo::Tasks.run_turbo_install_template "turbo_with_importmap"
46
+ Turbo::Tasks.switch_on_redis_if_available
36
47
  end
37
48
 
38
49
  desc "Install Turbo into the app with webpacker"
39
50
  task :node do
40
- run_turbo_install_template "turbo_with_node"
41
- switch_on_redis_if_available
51
+ Turbo::Tasks.run_turbo_install_template "turbo_with_node"
52
+ Turbo::Tasks.switch_on_redis_if_available
53
+ end
54
+
55
+ desc "Install Turbo into the app with bun"
56
+ task :bun do
57
+ Turbo::Tasks.run_turbo_install_template "turbo_with_bun"
58
+ Turbo::Tasks.switch_on_redis_if_available
42
59
  end
43
60
 
44
61
  desc "Switch on Redis and use it in development"
45
62
  task :redis do
46
- run_turbo_install_template "turbo_needs_redis"
63
+ Turbo::Tasks.run_turbo_install_template "turbo_needs_redis"
47
64
  end
48
65
  end
49
66
  end
@@ -0,0 +1,172 @@
1
+ module Turbo
2
+ module Broadcastable
3
+ module TestHelper
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ include ActionCable::TestHelper
8
+
9
+ include Turbo::Streams::StreamName
10
+ end
11
+
12
+ # Asserts that `<turbo-stream>` elements were broadcast over Action Cable
13
+ #
14
+ # === Arguments
15
+ #
16
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
17
+ # channel Action Cable name, or the name itself
18
+ # * <tt>&block</tt> optional block executed before the
19
+ # assertion
20
+ #
21
+ # === Options
22
+ #
23
+ # * <tt>count:</tt> the number of `<turbo-stream>` elements that are
24
+ # expected to be broadcast
25
+ #
26
+ # Asserts `<turbo-stream>` elements were broadcast:
27
+ #
28
+ # message = Message.find(1)
29
+ # message.broadcast_replace_to "messages"
30
+ #
31
+ # assert_turbo_stream_broadcasts "messages"
32
+ #
33
+ # Asserts that two `<turbo-stream>` elements were broadcast:
34
+ #
35
+ # message = Message.find(1)
36
+ # message.broadcast_replace_to "messages"
37
+ # message.broadcast_remove_to "messages"
38
+ #
39
+ # assert_turbo_stream_broadcasts "messages", count: 2
40
+ #
41
+ # You can pass a block to run before the assertion:
42
+ #
43
+ # message = Message.find(1)
44
+ #
45
+ # assert_turbo_stream_broadcasts "messages" do
46
+ # message.broadcast_append_to "messages"
47
+ # end
48
+ #
49
+ # In addition to a String, the helper also accepts an Object or Array to
50
+ # determine the name of the channel the elements are broadcast to:
51
+ #
52
+ # message = Message.find(1)
53
+ #
54
+ # assert_turbo_stream_broadcasts message do
55
+ # message.broadcast_replace
56
+ # end
57
+ #
58
+ def assert_turbo_stream_broadcasts(stream_name_or_object, count: nil, &block)
59
+ payloads = capture_turbo_stream_broadcasts(stream_name_or_object, &block)
60
+ stream_name = stream_name_from(stream_name_or_object)
61
+
62
+ if count.nil?
63
+ assert_not_empty payloads, "Expected at least one broadcast on #{stream_name.inspect}, but there were none"
64
+ else
65
+ broadcasts = "Turbo Stream broadcast".pluralize(count)
66
+
67
+ assert count == payloads.count, "Expected #{count} #{broadcasts} on #{stream_name.inspect}, but there were none"
68
+ end
69
+ end
70
+
71
+ # Asserts that no `<turbo-stream>` elements were broadcast over Action Cable
72
+ #
73
+ # === Arguments
74
+ #
75
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
76
+ # channel Action Cable name, or the name itself
77
+ # * <tt>&block</tt> optional block executed before the
78
+ # assertion
79
+ #
80
+ # Asserts that no `<turbo-stream>` elements were broadcast:
81
+ #
82
+ # message = Message.find(1)
83
+ # message.broadcast_replace_to "messages"
84
+ #
85
+ # assert_no_turbo_stream_broadcasts "messages" # fails with MiniTest::Assertion error
86
+ #
87
+ # You can pass a block to run before the assertion:
88
+ #
89
+ # message = Message.find(1)
90
+ #
91
+ # assert_no_turbo_stream_broadcasts "messages" do
92
+ # # do something other than broadcast to "messages"
93
+ # end
94
+ #
95
+ # In addition to a String, the helper also accepts an Object or Array to
96
+ # determine the name of the channel the elements are broadcast to:
97
+ #
98
+ # message = Message.find(1)
99
+ #
100
+ # assert_no_turbo_stream_broadcasts message do
101
+ # # do something other than broadcast to "message_1"
102
+ # end
103
+ #
104
+ def assert_no_turbo_stream_broadcasts(stream_name_or_object, &block)
105
+ block&.call
106
+
107
+ stream_name = stream_name_from(stream_name_or_object)
108
+
109
+ payloads = broadcasts(stream_name)
110
+
111
+ assert payloads.empty?, "Expected no broadcasts on #{stream_name.inspect}, but there were #{payloads.count}"
112
+ end
113
+
114
+ # Captures any `<turbo-stream>` elements that were broadcast over Action Cable
115
+ #
116
+ # === Arguments
117
+ #
118
+ # * <tt>stream_name_or_object</tt> the objects used to generate the
119
+ # channel Action Cable name, or the name itself
120
+ # * <tt>&block</tt> optional block to capture broadcasts during execution
121
+ #
122
+ # Returns any `<turbo-stream>` elements that have been broadcast as an
123
+ # Array of <tt>Nokogiri::XML::Element</tt> instances
124
+ #
125
+ # message = Message.find(1)
126
+ # message.broadcast_append_to "messages"
127
+ # message.broadcast_prepend_to "messages"
128
+ #
129
+ # turbo_streams = capture_turbo_stream_broadcasts "messages"
130
+ #
131
+ # assert_equal "append", turbo_streams.first["action"]
132
+ # assert_equal "prepend", turbo_streams.second["action"]
133
+ #
134
+ # You can pass a block to limit the scope of the broadcasts being captured:
135
+ #
136
+ # message = Message.find(1)
137
+ #
138
+ # turbo_streams = capture_turbo_stream_broadcasts "messages" do
139
+ # message.broadcast_append_to "messages"
140
+ # end
141
+ #
142
+ # assert_equal "append", turbo_streams.first["action"]
143
+ #
144
+ # In addition to a String, the helper also accepts an Object or Array to
145
+ # determine the name of the channel the elements are broadcast to:
146
+ #
147
+ # message = Message.find(1)
148
+ #
149
+ # replace, remove = capture_turbo_stream_broadcasts message do
150
+ # message.broadcast_replace
151
+ # message.broadcast_remove
152
+ # end
153
+ #
154
+ # assert_equal "replace", replace["action"]
155
+ # assert_equal "replace", remove["action"]
156
+ #
157
+ def capture_turbo_stream_broadcasts(stream_name_or_object, &block)
158
+ block&.call
159
+
160
+ stream_name = stream_name_from(stream_name_or_object)
161
+ payloads = broadcasts(stream_name)
162
+
163
+ payloads.flat_map do |payload|
164
+ html = ActiveSupport::JSON.decode(payload)
165
+ document = Nokogiri::HTML5.parse(html)
166
+
167
+ document.at("body").element_children
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
data/lib/turbo/engine.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require "rails/engine"
2
- require "turbo/test_assertions"
3
2
 
4
3
  module Turbo
5
4
  class Engine < Rails::Engine
@@ -34,6 +33,12 @@ module Turbo
34
33
  end
35
34
  end
36
35
 
36
+ initializer "turbo.configs" do
37
+ config.after_initialize do |app|
38
+ Turbo.draw_routes = app.config.turbo.draw_routes != false
39
+ end
40
+ end
41
+
37
42
  initializer "turbo.helpers", before: :load_config_initializers do
38
43
  ActiveSupport.on_load(:action_controller_base) do
39
44
  include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation
@@ -69,8 +74,17 @@ module Turbo
69
74
 
70
75
  initializer "turbo.test_assertions" do
71
76
  ActiveSupport.on_load(:active_support_test_case) do
77
+ require "turbo/test_assertions"
78
+ require "turbo/broadcastable/test_helper"
79
+
72
80
  include Turbo::TestAssertions
73
81
  end
82
+
83
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
84
+ require "turbo/test_assertions/integration_test_assertions"
85
+
86
+ include Turbo::TestAssertions::IntegrationTestAssertions
87
+ end
74
88
  end
75
89
 
76
90
  initializer "turbo.integration_test_request_encoding" do