turbo-rails 0.5.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +113 -18
  3. data/Rakefile +19 -2
  4. data/app/assets/javascripts/turbo.js +4143 -1431
  5. data/app/assets/javascripts/turbo.min.js +29 -0
  6. data/app/assets/javascripts/turbo.min.js.map +1 -0
  7. data/app/channels/turbo/streams/broadcasts.rb +58 -22
  8. data/app/channels/turbo/streams/stream_name.rb +7 -0
  9. data/app/channels/turbo/streams_channel.rb +30 -2
  10. data/app/controllers/concerns/turbo/request_id_tracking.rb +12 -0
  11. data/app/controllers/turbo/frames/frame_request.rb +22 -8
  12. data/app/controllers/turbo/native/navigation.rb +19 -9
  13. data/app/helpers/turbo/drive_helper.rb +75 -3
  14. data/app/helpers/turbo/frames_helper.rb +21 -2
  15. data/app/helpers/turbo/includes_helper.rb +2 -0
  16. data/app/helpers/turbo/streams/action_helper.rb +34 -9
  17. data/app/helpers/turbo/streams_helper.rb +20 -7
  18. data/app/javascript/turbo/cable.js +6 -3
  19. data/app/javascript/turbo/cable_stream_source_element.js +19 -3
  20. data/app/javascript/turbo/fetch_requests.js +59 -0
  21. data/app/javascript/turbo/index.js +6 -0
  22. data/app/javascript/turbo/snakeize.js +31 -0
  23. data/app/jobs/turbo/streams/action_broadcast_job.rb +4 -2
  24. data/app/jobs/turbo/streams/broadcast_job.rb +2 -0
  25. data/app/jobs/turbo/streams/broadcast_stream_job.rb +7 -0
  26. data/app/models/concerns/turbo/broadcastable.rb +246 -38
  27. data/app/models/turbo/debouncer.rb +24 -0
  28. data/app/models/turbo/streams/tag_builder.rb +163 -21
  29. data/app/models/turbo/thread_debouncer.rb +28 -0
  30. data/app/views/layouts/turbo_rails/frame.html.erb +8 -0
  31. data/config/routes.rb +1 -2
  32. data/lib/install/turbo_needs_redis.rb +20 -0
  33. data/lib/install/turbo_with_bun.rb +9 -0
  34. data/lib/install/turbo_with_importmap.rb +5 -0
  35. data/lib/install/turbo_with_node.rb +9 -0
  36. data/lib/tasks/turbo_tasks.rake +50 -8
  37. data/lib/turbo/broadcastable/test_helper.rb +172 -0
  38. data/lib/turbo/engine.rb +40 -6
  39. data/lib/turbo/test_assertions/integration_test_assertions.rb +76 -0
  40. data/lib/turbo/test_assertions.rb +69 -8
  41. data/lib/turbo/version.rb +1 -1
  42. data/lib/turbo-rails.rb +12 -0
  43. metadata +48 -173
  44. data/.github/workflows/ci.yml +0 -30
  45. data/.gitignore +0 -2
  46. data/Gemfile +0 -6
  47. data/Gemfile.lock +0 -147
  48. data/lib/install/turbo_with_asset_pipeline.rb +0 -20
  49. data/lib/install/turbo_with_webpacker.rb +0 -24
  50. data/package.json +0 -47
  51. data/rollup.config.js +0 -23
  52. data/test/drive/drive_helper_test.rb +0 -8
  53. data/test/dummy/.babelrc +0 -18
  54. data/test/dummy/.gitignore +0 -3
  55. data/test/dummy/.postcssrc.yml +0 -3
  56. data/test/dummy/Rakefile +0 -6
  57. data/test/dummy/app/assets/config/manifest.js +0 -2
  58. data/test/dummy/app/assets/images/.keep +0 -0
  59. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  60. data/test/dummy/app/assets/stylesheets/scaffold.css +0 -80
  61. data/test/dummy/app/channels/application_cable/channel.rb +0 -4
  62. data/test/dummy/app/channels/application_cable/connection.rb +0 -4
  63. data/test/dummy/app/controllers/application_controller.rb +0 -2
  64. data/test/dummy/app/controllers/concerns/.keep +0 -0
  65. data/test/dummy/app/controllers/messages_controller.rb +0 -12
  66. data/test/dummy/app/controllers/trays_controller.rb +0 -17
  67. data/test/dummy/app/helpers/application_helper.rb +0 -2
  68. data/test/dummy/app/javascript/packs/application.js +0 -0
  69. data/test/dummy/app/jobs/application_job.rb +0 -2
  70. data/test/dummy/app/mailboxes/application_mailbox.rb +0 -2
  71. data/test/dummy/app/mailboxes/messages_mailbox.rb +0 -4
  72. data/test/dummy/app/mailers/application_mailer.rb +0 -4
  73. data/test/dummy/app/models/application_record.rb +0 -3
  74. data/test/dummy/app/models/concerns/.keep +0 -0
  75. data/test/dummy/app/models/message.rb +0 -29
  76. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  77. data/test/dummy/app/views/layouts/mailer.html.erb +0 -13
  78. data/test/dummy/app/views/layouts/mailer.text.erb +0 -1
  79. data/test/dummy/app/views/messages/_message.html.erb +0 -1
  80. data/test/dummy/app/views/messages/_message.turbo_stream.erb +0 -1
  81. data/test/dummy/app/views/messages/show.turbo_stream.erb +0 -9
  82. data/test/dummy/app/views/trays/index.html.erb +0 -3
  83. data/test/dummy/app/views/trays/show.html.erb +0 -3
  84. data/test/dummy/bin/bundle +0 -3
  85. data/test/dummy/bin/rails +0 -4
  86. data/test/dummy/bin/rake +0 -4
  87. data/test/dummy/bin/setup +0 -36
  88. data/test/dummy/bin/update +0 -31
  89. data/test/dummy/bin/yarn +0 -11
  90. data/test/dummy/config/application.rb +0 -22
  91. data/test/dummy/config/boot.rb +0 -5
  92. data/test/dummy/config/cable.yml +0 -10
  93. data/test/dummy/config/environment.rb +0 -5
  94. data/test/dummy/config/environments/development.rb +0 -34
  95. data/test/dummy/config/environments/production.rb +0 -96
  96. data/test/dummy/config/environments/test.rb +0 -38
  97. data/test/dummy/config/initializers/application_controller_renderer.rb +0 -8
  98. data/test/dummy/config/initializers/assets.rb +0 -14
  99. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  100. data/test/dummy/config/initializers/content_security_policy.rb +0 -22
  101. data/test/dummy/config/initializers/cookies_serializer.rb +0 -5
  102. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  103. data/test/dummy/config/initializers/inflections.rb +0 -16
  104. data/test/dummy/config/initializers/mime_types.rb +0 -4
  105. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  106. data/test/dummy/config/locales/en.yml +0 -33
  107. data/test/dummy/config/puma.rb +0 -34
  108. data/test/dummy/config/routes.rb +0 -4
  109. data/test/dummy/config/spring.rb +0 -6
  110. data/test/dummy/config/webpack/development.js +0 -3
  111. data/test/dummy/config/webpack/environment.js +0 -3
  112. data/test/dummy/config/webpack/production.js +0 -3
  113. data/test/dummy/config/webpack/test.js +0 -3
  114. data/test/dummy/config/webpacker.yml +0 -65
  115. data/test/dummy/config.ru +0 -5
  116. data/test/dummy/lib/assets/.keep +0 -0
  117. data/test/dummy/log/.keep +0 -0
  118. data/test/dummy/public/404.html +0 -67
  119. data/test/dummy/public/422.html +0 -67
  120. data/test/dummy/public/500.html +0 -66
  121. data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
  122. data/test/dummy/public/apple-touch-icon.png +0 -0
  123. data/test/dummy/public/favicon.ico +0 -0
  124. data/test/frames/frame_request_controller_test.rb +0 -21
  125. data/test/frames/frames_helper_test.rb +0 -21
  126. data/test/native/navigation_controller_test.rb +0 -42
  127. data/test/streams/broadcastable_test.rb +0 -80
  128. data/test/streams/streams_channel_test.rb +0 -105
  129. data/test/streams/streams_controller_test.rb +0 -29
  130. data/test/turbo_test.rb +0 -10
  131. data/turbo-rails.gemspec +0 -17
  132. data/yarn.lock +0 -283
@@ -14,7 +14,7 @@
14
14
  # <%= turbo_stream.append "entries" do %>
15
15
  # <% # format is automatically switched, such that _entry.html.erb partial is rendered, not _entry.turbo_stream.erb %>
16
16
  # <%= render partial: "entries/entry", locals: { entry: entry } %>
17
- # <%= end %>
17
+ # <% end %>
18
18
  #
19
19
  # Or you can render the HTML that should be part of the update inline:
20
20
  #
@@ -22,11 +22,30 @@
22
22
  # <%= turbo_stream.append dom_id(topic_merge) do %>
23
23
  # <%= link_to topic_merge.topic.name, topic_path(topic_merge.topic) %>
24
24
  # <% end %>
25
+ #
26
+ # To integrate with custom actions, extend this class in response to the :turbo_streams_tag_builder load hook:
27
+ #
28
+ # ActiveSupport.on_load :turbo_streams_tag_builder do
29
+ # def highlight(target)
30
+ # action :highlight, target
31
+ # end
32
+ #
33
+ # def highlight_all(targets)
34
+ # action_all :highlight, targets
35
+ # end
36
+ # end
37
+ #
38
+ # turbo_stream.highlight "my-element"
39
+ # # => <turbo-stream action="highlight" target="my-element"><template></template></turbo-stream>
40
+ #
41
+ # turbo_stream.highlight_all ".my-selector"
42
+ # # => <turbo-stream action="highlight" targets=".my-selector"><template></template></turbo-stream>
25
43
  class Turbo::Streams::TagBuilder
26
44
  include Turbo::Streams::ActionHelper
27
45
 
28
46
  def initialize(view_context)
29
47
  @view_context = view_context
48
+ @view_context.formats |= [:html]
30
49
  end
31
50
 
32
51
  # Removes the <tt>target</tt> from the dom. The target can either be a dom id string or an object that responds to
@@ -39,7 +58,17 @@ class Turbo::Streams::TagBuilder
39
58
  action :remove, target, allow_inferred_rendering: false
40
59
  end
41
60
 
42
- # Replace the <tt>target</tt> in the dom with the either the <tt>content</tt> passed in, a rendering result determined
61
+ # Removes the <tt>targets</tt> from the dom. The targets can either be a CSS selector string or an object that responds to
62
+ # <tt>to_key</tt>, which is then called and passed through <tt>ActionView::RecordIdentifier.dom_id</tt> (all Active Records
63
+ # do). Examples:
64
+ #
65
+ # <%= turbo_stream.remove_all ".clearance_item" %>
66
+ # <%= turbo_stream.remove_all clearance %>
67
+ def remove_all(targets)
68
+ action_all :remove, targets, allow_inferred_rendering: false
69
+ end
70
+
71
+ # Replace the <tt>target</tt> in the dom with either the <tt>content</tt> passed in, a rendering result determined
43
72
  # by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
44
73
  #
45
74
  # <%= turbo_stream.replace "clearance_5", "<div id='clearance_5'>Replace the dom target identified by clearance_5</div>" %>
@@ -52,7 +81,72 @@ class Turbo::Streams::TagBuilder
52
81
  action :replace, target, content, **rendering, &block
53
82
  end
54
83
 
55
- # Update the <tt>target</tt> in the dom with the either the <tt>content</tt> passed in or a rendering result determined
84
+ # Replace the <tt>targets</tt> in the dom with either the <tt>content</tt> passed in, a rendering result determined
85
+ # by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
86
+ #
87
+ # <%= turbo_stream.replace_all ".clearance_item", "<div class='clearance_item'>Replace the dom target identified by the class clearance_item</div>" %>
88
+ # <%= turbo_stream.replace_all clearance %>
89
+ # <%= turbo_stream.replace_all clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
90
+ # <%= turbo_stream.replace_all ".clearance_item" do %>
91
+ # <div class='.clearance_item'>Replace the dom target identified by the class clearance_item</div>
92
+ # <% end %>
93
+ def replace_all(targets, content = nil, **rendering, &block)
94
+ action_all :replace, targets, content, **rendering, &block
95
+ end
96
+
97
+ # Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
98
+ # the content in the block, or the rendering of the target as a record before the <tt>target</tt> in the dom. Examples:
99
+ #
100
+ # <%= turbo_stream.before "clearance_5", "<div id='clearance_4'>Insert before the dom target identified by clearance_5</div>" %>
101
+ # <%= turbo_stream.before clearance %>
102
+ # <%= turbo_stream.before clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
103
+ # <%= turbo_stream.before "clearance_5" do %>
104
+ # <div id='clearance_4'>Insert before the dom target identified by clearance_5</div>
105
+ # <% end %>
106
+ def before(target, content = nil, **rendering, &block)
107
+ action :before, target, content, **rendering, &block
108
+ end
109
+
110
+ # Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
111
+ # the content in the block, or the rendering of the target as a record before the <tt>targets</tt> in the dom. Examples:
112
+ #
113
+ # <%= turbo_stream.before_all ".clearance_item", "<div class='clearance_item'>Insert before the dom target identified by the class clearance_item</div>" %>
114
+ # <%= turbo_stream.before_all clearance %>
115
+ # <%= turbo_stream.before_all clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
116
+ # <%= turbo_stream.before_all ".clearance_item" do %>
117
+ # <div class='clearance_item'>Insert before the dom target identified by clearance_item</div>
118
+ # <% end %>
119
+ def before_all(targets, content = nil, **rendering, &block)
120
+ action_all :before, targets, content, **rendering, &block
121
+ end
122
+
123
+ # Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
124
+ # the content in the block, or the rendering of the target as a record after the <tt>target</tt> in the dom. Examples:
125
+ #
126
+ # <%= turbo_stream.after "clearance_5", "<div id='clearance_6'>Insert after the dom target identified by clearance_5</div>" %>
127
+ # <%= turbo_stream.after clearance %>
128
+ # <%= turbo_stream.after clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
129
+ # <%= turbo_stream.after "clearance_5" do %>
130
+ # <div id='clearance_6'>Insert after the dom target identified by clearance_5</div>
131
+ # <% end %>
132
+ def after(target, content = nil, **rendering, &block)
133
+ action :after, target, content, **rendering, &block
134
+ end
135
+
136
+ # Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
137
+ # the content in the block, or the rendering of the target as a record after the <tt>targets</tt> in the dom. Examples:
138
+ #
139
+ # <%= turbo_stream.after_all ".clearance_item", "<div class='clearance_item'>Insert after the dom target identified by the class clearance_item</div>" %>
140
+ # <%= turbo_stream.after_all clearance %>
141
+ # <%= turbo_stream.after_all clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
142
+ # <%= turbo_stream.after_all "clearance_item" do %>
143
+ # <div class='clearance_item'>Insert after the dom target identified by the class clearance_item</div>
144
+ # <% end %>
145
+ def after_all(targets, content = nil, **rendering, &block)
146
+ action_all :after, targets, content, **rendering, &block
147
+ end
148
+
149
+ # Update the <tt>target</tt> in the dom with either the <tt>content</tt> passed in or a rendering result determined
56
150
  # by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
57
151
  #
58
152
  # <%= turbo_stream.update "clearance_5", "Update the content of the dom target identified by clearance_5" %>
@@ -65,6 +159,19 @@ class Turbo::Streams::TagBuilder
65
159
  action :update, target, content, **rendering, &block
66
160
  end
67
161
 
162
+ # Update the <tt>targets</tt> in the dom with either the <tt>content</tt> passed in or a rendering result determined
163
+ # by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the targets as a record. Examples:
164
+ #
165
+ # <%= turbo_stream.update_all "clearance_item", "Update the content of the dom target identified by the class clearance_item" %>
166
+ # <%= turbo_stream.update_all clearance %>
167
+ # <%= turbo_stream.update_all clearance, partial: "clearances/new_clearance", locals: { title: "Hello" } %>
168
+ # <%= turbo_stream.update_all "clearance_item" do %>
169
+ # Update the content of the dom target identified by the class clearance_item
170
+ # <% end %>
171
+ def update_all(targets, content = nil, **rendering, &block)
172
+ action_all :update, targets, content, **rendering, &block
173
+ end
174
+
68
175
  # Append to the target in the dom identified with <tt>target</tt> either the <tt>content</tt> passed in or a
69
176
  # rendering result determined by the <tt>rendering</tt> keyword arguments, the content in the block,
70
177
  # or the rendering of the content as a record. Examples:
@@ -79,6 +186,20 @@ class Turbo::Streams::TagBuilder
79
186
  action :append, target, content, **rendering, &block
80
187
  end
81
188
 
189
+ # Append to the targets in the dom identified with <tt>targets</tt> either the <tt>content</tt> passed in or a
190
+ # rendering result determined by the <tt>rendering</tt> keyword arguments, the content in the block,
191
+ # or the rendering of the content as a record. Examples:
192
+ #
193
+ # <%= turbo_stream.append_all ".clearances", "<div class='clearance_item'>Append this to .clearance_group</div>" %>
194
+ # <%= turbo_stream.append_all ".clearances", clearance %>
195
+ # <%= turbo_stream.append_all ".clearances", partial: "clearances/new_clearance", locals: { clearance: clearance } %>
196
+ # <%= turbo_stream.append_all ".clearances" do %>
197
+ # <div id='clearance_item'>Append this to .clearances</div>
198
+ # <% end %>
199
+ def append_all(targets, content = nil, **rendering, &block)
200
+ action_all :append, targets, content, **rendering, &block
201
+ end
202
+
82
203
  # Prepend to the target in the dom identified with <tt>target</tt> either the <tt>content</tt> passed in or a
83
204
  # rendering result determined by the <tt>rendering</tt> keyword arguments or the content in the block,
84
205
  # or the rendering of the content as a record. Examples:
@@ -93,35 +214,56 @@ class Turbo::Streams::TagBuilder
93
214
  action :prepend, target, content, **rendering, &block
94
215
  end
95
216
 
96
- # Send an action of the type <tt>name</tt>. Options described in the concrete methods.
217
+ # Prepend to the targets in the dom identified with <tt>targets</tt> either the <tt>content</tt> passed in or a
218
+ # rendering result determined by the <tt>rendering</tt> keyword arguments or the content in the block,
219
+ # or the rendering of the content as a record. Examples:
220
+ #
221
+ # <%= turbo_stream.prepend_all ".clearances", "<div class='clearance_item'>Prepend this to .clearances</div>" %>
222
+ # <%= turbo_stream.prepend_all ".clearances", clearance %>
223
+ # <%= turbo_stream.prepend_all ".clearances", partial: "clearances/new_clearance", locals: { clearance: clearance } %>
224
+ # <%= turbo_stream.prepend_all ".clearances" do %>
225
+ # <div class='clearance_item'>Prepend this to .clearances</div>
226
+ # <% end %>
227
+ def prepend_all(targets, content = nil, **rendering, &block)
228
+ action_all :prepend, targets, content, **rendering, &block
229
+ end
230
+
231
+ # Send an action of the type <tt>name</tt> to <tt>target</tt>. Options described in the concrete methods.
97
232
  def action(name, target, content = nil, allow_inferred_rendering: true, **rendering, &block)
98
- target_name = extract_target_name_from(target)
99
-
100
- case
101
- when content
102
- turbo_stream_action_tag name, target: target_name, template: (render_record(content) if allow_inferred_rendering) || content
103
- when block_given?
104
- turbo_stream_action_tag name, target: target_name, template: @view_context.capture(&block)
105
- when rendering.any?
106
- turbo_stream_action_tag name, target: target_name, template: @view_context.render(formats: [ :html ], **rendering)
107
- else
108
- turbo_stream_action_tag name, target: target_name, template: (render_record(target) if allow_inferred_rendering)
109
- end
233
+ template = render_template(target, content, allow_inferred_rendering: allow_inferred_rendering, **rendering, &block)
234
+
235
+ turbo_stream_action_tag name, target: target, template: template
236
+ end
237
+
238
+ # Send an action of the type <tt>name</tt> to <tt>targets</tt>. Options described in the concrete methods.
239
+ def action_all(name, targets, content = nil, allow_inferred_rendering: true, **rendering, &block)
240
+ template = render_template(targets, content, allow_inferred_rendering: allow_inferred_rendering, **rendering, &block)
241
+
242
+ turbo_stream_action_tag name, targets: targets, template: template
110
243
  end
111
244
 
112
245
  private
113
- def extract_target_name_from(target)
114
- if target.respond_to?(:to_key)
115
- ActionView::RecordIdentifier.dom_id(target)
246
+ def render_template(target, content = nil, allow_inferred_rendering: true, **rendering, &block)
247
+ case
248
+ when content.respond_to?(:render_in)
249
+ content.render_in(@view_context, &block)
250
+ when content
251
+ allow_inferred_rendering ? (render_record(content) || content) : content
252
+ when block_given?
253
+ @view_context.capture(&block)
254
+ when rendering.any?
255
+ @view_context.render(formats: [ :html ], **rendering)
116
256
  else
117
- target
257
+ render_record(target) if allow_inferred_rendering
118
258
  end
119
259
  end
120
260
 
121
261
  def render_record(possible_record)
122
262
  if possible_record.respond_to?(:to_partial_path)
123
263
  record = possible_record
124
- @view_context.render(partial: record.to_partial_path, locals: { record.model_name.singular.to_sym => record }, formats: [ :html ])
264
+ @view_context.render(partial: record, formats: :html)
125
265
  end
126
266
  end
267
+
268
+ ActiveSupport.run_load_hooks :turbo_streams_tag_builder, self
127
269
  end
@@ -0,0 +1,28 @@
1
+ # A decorated debouncer that will store instances in the current thread clearing them
2
+ # after the debounced logic triggers.
3
+ class Turbo::ThreadDebouncer
4
+ delegate :wait, to: :debouncer
5
+
6
+ def self.for(key, delay: Turbo::Debouncer::DEFAULT_DELAY)
7
+ Thread.current[key] ||= new(key, Thread.current, delay: delay)
8
+ end
9
+
10
+ private_class_method :new
11
+
12
+ def initialize(key, thread, delay: )
13
+ @key = key
14
+ @debouncer = Turbo::Debouncer.new(delay: delay)
15
+ @thread = thread
16
+ end
17
+
18
+ def debounce
19
+ debouncer.debounce do
20
+ yield.tap do
21
+ thread[key] = nil
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+ attr_reader :key, :debouncer, :thread
28
+ end
@@ -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
@@ -1,6 +1,5 @@
1
- # FIXME: Offer flag to opt out of these native routes
2
1
  Rails.application.routes.draw do
3
2
  get "recede_historical_location" => "turbo/native/navigation#recede", as: :turbo_recede_historical_location
4
3
  get "resume_historical_location" => "turbo/native/navigation#resume", as: :turbo_resume_historical_location
5
4
  get "refresh_historical_location" => "turbo/native/navigation#refresh", as: :turbo_refresh_historical_location
6
- end
5
+ end if Turbo.draw_routes
@@ -0,0 +1,20 @@
1
+ if (cable_config_path = Rails.root.join("config/cable.yml")).exist?
2
+ say "Enable redis in bundle"
3
+
4
+ gemfile_content = File.read(Rails.root.join("Gemfile"))
5
+ pattern = /gem ['"]redis['"]/
6
+
7
+ if gemfile_content.match?(pattern)
8
+ uncomment_lines "Gemfile", pattern
9
+ else
10
+ append_file "Gemfile", "\n# Use Redis for Action Cable"
11
+ gem 'redis', '~> 4.0'
12
+ end
13
+
14
+ run_bundle
15
+
16
+ say "Switch development cable to use redis"
17
+ gsub_file cable_config_path.to_s, /development:\n\s+adapter: async/, "development:\n adapter: redis\n url: redis://localhost:6379/1"
18
+ else
19
+ say 'ActionCable config file (config/cable.yml) is missing. Uncomment "gem \'redis\'" in your Gemfile and create config/cable.yml to use the Turbo Streams broadcast feature.'
20
+ end
@@ -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"
@@ -0,0 +1,5 @@
1
+ say "Import Turbo"
2
+ append_to_file "app/javascript/application.js", %(import "@hotwired/turbo-rails"\n)
3
+
4
+ say "Pin Turbo"
5
+ append_to_file "config/importmap.rb", %(pin "@hotwired/turbo-rails", to: "turbo.min.js"\n)
@@ -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 "yarn add @hotwired/turbo-rails"
@@ -1,24 +1,66 @@
1
- def run_install_template(path) system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{File.expand_path("../install/#{path}.rb", __dir__)}" 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
7
+
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
13
+
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
25
+ end
26
+ end
2
27
 
3
28
  namespace :turbo do
4
29
  desc "Install Turbo into the app"
5
30
  task :install do
6
- if defined?(Webpacker::Engine)
7
- Rake::Task["turbo:install:webpacker"].invoke
31
+ if Rails.root.join("config/importmap.rb").exist?
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
35
+ elsif Rails.root.join("package.json").exist?
36
+ Rake::Task["turbo:install:node"].invoke
8
37
  else
9
- Rake::Task["turbo:install:asset_pipeline"].invoke
38
+ puts "You must either be running with node (package.json) or importmap-rails (config/importmap.rb) to use this gem."
10
39
  end
11
40
  end
12
41
 
13
42
  namespace :install do
14
43
  desc "Install Turbo into the app with asset pipeline"
15
- task :asset_pipeline do
16
- run_install_template "turbo_with_asset_pipeline"
44
+ task :importmap do
45
+ Turbo::Tasks.run_turbo_install_template "turbo_with_importmap"
46
+ Turbo::Tasks.switch_on_redis_if_available
17
47
  end
18
48
 
19
49
  desc "Install Turbo into the app with webpacker"
20
- task :webpacker do
21
- run_install_template "turbo_with_webpacker"
50
+ task :node do
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
59
+ end
60
+
61
+ desc "Switch on Redis and use it in development"
62
+ task :redis do
63
+ Turbo::Tasks.run_turbo_install_template "turbo_needs_redis"
22
64
  end
23
65
  end
24
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 #{payloads.count}"
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