turbo-rails 1.3.0 → 1.5.0

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