turbo-rails 1.5.0 → 2.0.7

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -16
  3. data/app/assets/javascripts/turbo.js +1974 -785
  4. data/app/assets/javascripts/turbo.min.js +9 -5
  5. data/app/assets/javascripts/turbo.min.js.map +1 -1
  6. data/app/channels/turbo/streams/broadcasts.rb +33 -7
  7. data/app/channels/turbo/streams_channel.rb +15 -15
  8. data/app/controllers/concerns/turbo/request_id_tracking.rb +12 -0
  9. data/app/controllers/turbo/frames/frame_request.rb +2 -2
  10. data/app/controllers/turbo/native/navigation.rb +6 -3
  11. data/app/helpers/turbo/drive_helper.rb +72 -14
  12. data/app/helpers/turbo/frames_helper.rb +8 -8
  13. data/app/helpers/turbo/streams/action_helper.rb +12 -4
  14. data/app/helpers/turbo/streams_helper.rb +5 -0
  15. data/app/javascript/turbo/index.js +2 -0
  16. data/app/jobs/turbo/streams/action_broadcast_job.rb +2 -2
  17. data/app/jobs/turbo/streams/broadcast_job.rb +1 -1
  18. data/app/jobs/turbo/streams/broadcast_stream_job.rb +7 -0
  19. data/app/models/concerns/turbo/broadcastable.rb +184 -35
  20. data/app/models/turbo/debouncer.rb +24 -0
  21. data/app/models/turbo/streams/tag_builder.rb +20 -0
  22. data/app/models/turbo/thread_debouncer.rb +28 -0
  23. data/config/routes.rb +3 -4
  24. data/lib/install/turbo_with_importmap.rb +1 -1
  25. data/lib/tasks/turbo_tasks.rake +0 -22
  26. data/lib/turbo/broadcastable/test_helper.rb +5 -5
  27. data/lib/turbo/engine.rb +34 -8
  28. data/lib/turbo/test_assertions/integration_test_assertions.rb +2 -2
  29. data/lib/turbo/test_assertions.rb +2 -2
  30. data/lib/turbo/version.rb +1 -1
  31. data/lib/turbo-rails.rb +10 -0
  32. metadata +9 -5
  33. data/lib/install/turbo_needs_redis.rb +0 -20
@@ -1,4 +1,4 @@
1
- # Provides the broadcast actions in synchronous and asynchrous form for the <tt>Turbo::StreamsChannel</tt>.
1
+ # Provides the broadcast actions in synchronous and asynchronous form for the <tt>Turbo::StreamsChannel</tt>.
2
2
  # See <tt>Turbo::Broadcastable</tt> for the user-facing API that invokes these methods with most of the paperwork filled out already.
3
3
  #
4
4
  # Can be used directly using something like <tt>Turbo::StreamsChannel.broadcast_remove_to :entries, target: 1</tt>.
@@ -33,9 +33,14 @@ module Turbo::Streams::Broadcasts
33
33
  broadcast_action_to(*streamables, action: :prepend, **opts)
34
34
  end
35
35
 
36
- def broadcast_action_to(*streamables, action:, target: nil, targets: nil, **rendering)
36
+ def broadcast_refresh_to(*streamables, **opts)
37
+ broadcast_stream_to(*streamables, content: turbo_stream_refresh_tag)
38
+ end
39
+
40
+ def broadcast_action_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
37
41
  broadcast_stream_to(*streamables, content: turbo_stream_action_tag(action, target: target, targets: targets, template:
38
- rendering.delete(:content) || rendering.delete(:html) || (rendering.any? ? render_format(:html, **rendering) : nil)
42
+ rendering.delete(:content) || rendering.delete(:html) || (rendering[:render] != false && rendering.any? ? render_format(:html, **rendering) : nil),
43
+ **attributes
39
44
  ))
40
45
  end
41
46
 
@@ -63,9 +68,22 @@ module Turbo::Streams::Broadcasts
63
68
  broadcast_action_later_to(*streamables, action: :prepend, **opts)
64
69
  end
65
70
 
66
- def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, **rendering)
67
- Turbo::Streams::ActionBroadcastJob.perform_later \
68
- stream_name_from(streamables), action: action, target: target, targets: targets, **rendering
71
+ def broadcast_refresh_later_to(*streamables, request_id: Turbo.current_request_id, **opts)
72
+ refresh_debouncer_for(*streamables, request_id: request_id).debounce do
73
+ Turbo::Streams::BroadcastStreamJob.perform_later stream_name_from(streamables), content: turbo_stream_refresh_tag(request_id: request_id, **opts)
74
+ end
75
+ end
76
+
77
+ def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
78
+ streamables.flatten!
79
+ streamables.compact_blank!
80
+
81
+ if streamables.present?
82
+ target = convert_to_turbo_stream_dom_id(target)
83
+ targets = convert_to_turbo_stream_dom_id(targets, include_selector: true)
84
+ Turbo::Streams::ActionBroadcastJob.perform_later \
85
+ stream_name_from(streamables), action: action, target: target, targets: targets, attributes: attributes, **rendering
86
+ end
69
87
  end
70
88
 
71
89
  def broadcast_render_to(*streamables, **rendering)
@@ -77,9 +95,17 @@ module Turbo::Streams::Broadcasts
77
95
  end
78
96
 
79
97
  def broadcast_stream_to(*streamables, content:)
80
- ActionCable.server.broadcast stream_name_from(streamables), content
98
+ streamables.flatten!
99
+ streamables.compact_blank!
100
+
101
+ if streamables.present?
102
+ ActionCable.server.broadcast stream_name_from(streamables), content
103
+ end
81
104
  end
82
105
 
106
+ def refresh_debouncer_for(*streamables, request_id: nil) # :nodoc:
107
+ Turbo::ThreadDebouncer.for("turbo-refresh-debouncer-#{stream_name_from(streamables.including(request_id))}")
108
+ end
83
109
 
84
110
  private
85
111
  def render_format(format, **rendering)
@@ -9,27 +9,27 @@
9
9
  # helper modules like <tt>Turbo::Streams::StreamName</tt>:
10
10
  #
11
11
  # class CustomChannel < ActionCable::Channel::Base
12
- # extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
13
- # include Turbo::Streams::StreamName::ClassMethods
12
+ # extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
13
+ # include Turbo::Streams::StreamName::ClassMethods
14
14
  #
15
- # def subscribed
16
- # if (stream_name = verified_stream_name_from_params).present? &&
15
+ # def subscribed
16
+ # if (stream_name = verified_stream_name_from_params).present? &&
17
17
  # subscription_allowed?
18
- # stream_from stream_name
19
- # else
20
- # reject
21
- # end
22
- # end
18
+ # stream_from stream_name
19
+ # else
20
+ # reject
21
+ # end
22
+ # end
23
23
  #
24
- # def subscription_allowed?
25
- # # ...
26
- # end
24
+ # def subscription_allowed?
25
+ # # ...
26
+ # end
27
27
  # end
28
28
  #
29
- # This channel can be connected to a web page using <tt>:channel</tt> option in
30
- # <tt>turbo_stream_from</tt> helper:
29
+ # This channel can be connected to a web page using <tt>:channel</tt> option in
30
+ # <tt>turbo_stream_from</tt> helper:
31
31
  #
32
- # <%= turbo_stream_from 'room', channel: CustomChannel %>
32
+ # <%= turbo_stream_from 'room', channel: CustomChannel %>
33
33
  #
34
34
  class Turbo::StreamsChannel < ActionCable::Channel::Base
35
35
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
@@ -0,0 +1,12 @@
1
+ module Turbo::RequestIdTracking
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ around_action :turbo_tracking_request_id
6
+ end
7
+
8
+ private
9
+ def turbo_tracking_request_id(&block)
10
+ Turbo.with_request_id(request.headers["X-Turbo-Request-Id"], &block)
11
+ end
12
+ end
@@ -5,7 +5,7 @@
5
5
  # When that header is detected by the controller, we substitute our own minimal layout in place of the
6
6
  # application-supplied layout (since we're only working on an in-page frame, thus can skip the weight of the layout). We
7
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>.
8
+ # <tt>head</tt>.
9
9
  #
10
10
  # Accordingly, we ensure that the etag for the page is changed, such that a cache for a minimal-layout request isn't
11
11
  # served on a normal request and vice versa.
@@ -24,7 +24,7 @@ module Turbo::Frames::FrameRequest
24
24
  layout -> { "turbo_rails/frame" if turbo_frame_request? }
25
25
  etag { :frame if turbo_frame_request? }
26
26
 
27
- helper_method :turbo_frame_request_id
27
+ helper_method :turbo_frame_request?, :turbo_frame_request_id
28
28
  end
29
29
 
30
30
  private
@@ -15,29 +15,32 @@ module Turbo::Native::Navigation
15
15
  request.user_agent.to_s.match?(/Turbo Native/)
16
16
  end
17
17
 
18
- # Tell the Turbo Native app to dismiss a modal (if presented) or pop a screen off of the navigation stack.
18
+ # Tell the Turbo Native app to dismiss a modal (if presented) or pop a screen off of the navigation stack. Otherwise redirect to the given URL if Turbo Native is not present.
19
19
  def recede_or_redirect_to(url, **options)
20
20
  turbo_native_action_or_redirect url, :recede, :to, options
21
21
  end
22
22
 
23
- # Tell the Turbo Native app to ignore this navigation.
23
+ # Tell the Turbo Native app to ignore this navigation, otherwise redirect to the given URL if Turbo Native is not present.
24
24
  def resume_or_redirect_to(url, **options)
25
25
  turbo_native_action_or_redirect url, :resume, :to, options
26
26
  end
27
27
 
28
- # Tell the Turbo Native app to refresh the current screen.
28
+ # Tell the Turbo Native app to refresh the current screen, otherwise redirect to the given URL if Turbo Native is not present.
29
29
  def refresh_or_redirect_to(url, **options)
30
30
  turbo_native_action_or_redirect url, :refresh, :to, options
31
31
  end
32
32
 
33
+ # Same as <tt>recede_or_redirect_to</tt> but redirects to the previous page or provided fallback location if the Turbo Native app is not present.
33
34
  def recede_or_redirect_back_or_to(url, **options)
34
35
  turbo_native_action_or_redirect url, :recede, :back, options
35
36
  end
36
37
 
38
+ # Same as <tt>resume_or_redirect_to</tt> but redirects to the previous page or provided fallback location if the Turbo Native app is not present.
37
39
  def resume_or_redirect_back_or_to(url, **options)
38
40
  turbo_native_action_or_redirect url, :resume, :back, options
39
41
  end
40
42
 
43
+ # Same as <tt>refresh_or_redirect_to</tt> but redirects to the previous page or provided fallback location if the Turbo Native app is not present.
41
44
  def refresh_or_redirect_back_or_to(url, **options)
42
45
  turbo_native_action_or_redirect url, :refresh, :back, options
43
46
  end
@@ -1,29 +1,87 @@
1
+ # Helpers to configure Turbo Drive via meta directives. They come in two
2
+ # variants:
3
+ #
4
+ # The recommended option is to include +yield :head+ in the +<head>+ section
5
+ # of the layout. Then you can use the helpers in any view.
6
+ #
7
+ # ==== Example
8
+ #
9
+ # # app/views/application.html.erb
10
+ # <html><head><%= yield :head %></head><body><%= yield %></html>
11
+ #
12
+ # # app/views/trays/index.html.erb
13
+ # <% turbo_exempts_page_from_cache %>
14
+ # <p>Page that shouldn't be cached by Turbo</p>
15
+ #
16
+ # Alternatively, you can use the +_tag+ variant of the helpers to only get the
17
+ # HTML for the meta directive.
1
18
  module Turbo::DriveHelper
2
- # Note: These helpers require a +yield :head+ provision in the layout.
3
- #
4
- # ==== Example
5
- #
6
- # # app/views/application.html.erb
7
- # <html><head><%= yield :head %></head><body><%= yield %></html>
8
- #
9
- # # app/views/trays/index.html.erb
10
- # <% turbo_exempts_page_from_cache %>
11
- # <p>Page that shouldn't be cached by Turbo</p>
12
-
13
19
  # Pages that are more likely than not to be a cache miss can skip turbo cache to avoid visual jitter.
14
20
  # Cannot be used along with +turbo_exempts_page_from_preview+.
15
21
  def turbo_exempts_page_from_cache
16
- provide :head, tag.meta(name: "turbo-cache-control", content: "no-cache")
22
+ provide :head, turbo_exempts_page_from_cache_tag
23
+ end
24
+
25
+ # See +turbo_exempts_page_from_cache+.
26
+ def turbo_exempts_page_from_cache_tag
27
+ tag.meta(name: "turbo-cache-control", content: "no-cache")
17
28
  end
18
29
 
19
30
  # Specify that a cached version of the page should not be shown as a preview during an application visit.
20
31
  # Cannot be used along with +turbo_exempts_page_from_cache+.
21
32
  def turbo_exempts_page_from_preview
22
- provide :head, tag.meta(name: "turbo-cache-control", content: "no-preview")
33
+ provide :head, turbo_exempts_page_from_preview_tag
34
+ end
35
+
36
+ # See +turbo_exempts_page_from_preview+.
37
+ def turbo_exempts_page_from_preview_tag
38
+ tag.meta(name: "turbo-cache-control", content: "no-preview")
23
39
  end
24
40
 
25
41
  # Force the page, when loaded by Turbo, to be cause a full page reload.
26
42
  def turbo_page_requires_reload
27
- provide :head, tag.meta(name: "turbo-visit-control", content: "reload")
43
+ provide :head, turbo_page_requires_reload_tag
44
+ end
45
+
46
+ # See +turbo_page_requires_reload+.
47
+ def turbo_page_requires_reload_tag
48
+ tag.meta(name: "turbo-visit-control", content: "reload")
49
+ end
50
+
51
+ # Configure how to handle page refreshes. A page refresh happens when
52
+ # Turbo loads the current page again with a *replace* visit:
53
+ #
54
+ # ==== Parameters:
55
+ #
56
+ # * <tt>method</tt> - Method to update the +<body>+ of the page
57
+ # during a page refresh. It can be one of:
58
+ # * +replace:+: Replaces the existing +<body>+ with the new one. This is the
59
+ # default behavior.
60
+ # * +morph:+: Morphs the existing +<body>+ into the new one.
61
+ #
62
+ # * <tt>scroll</tt> - Controls the scroll behavior when a page refresh happens. It
63
+ # can be one of:
64
+ # * +reset:+: Resets scroll to the top, left corner. This is the default.
65
+ # * +preserve:+: Keeps the scroll.
66
+ #
67
+ # ==== Example Usage:
68
+ #
69
+ # turbo_refreshes_with(method: :morph, scroll: :preserve)
70
+ def turbo_refreshes_with(method: :replace, scroll: :reset)
71
+ provide :head, turbo_refresh_method_tag(method)
72
+ provide :head, turbo_refresh_scroll_tag(scroll)
73
+ end
74
+
75
+ # Configure method to perform page refreshes. See +turbo_refreshes_with+.
76
+ def turbo_refresh_method_tag(method = :replace)
77
+ raise ArgumentError, "Invalid refresh option '#{method}'" unless method.in?(%i[ replace morph ])
78
+ tag.meta(name: "turbo-refresh-method", content: method)
79
+ end
80
+
81
+ # Configure scroll strategy for page refreshes. See +turbo_refreshes_with+.
82
+ def turbo_refresh_scroll_tag(scroll = :reset)
83
+ raise ArgumentError, "Invalid scroll option '#{scroll}'" unless scroll.in?(%i[ reset preserve ])
84
+ tag.meta(name: "turbo-refresh-scroll", content: scroll)
28
85
  end
29
86
  end
87
+
@@ -2,7 +2,7 @@ module Turbo::FramesHelper
2
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
3
  # fetches the URL supplied in the +src+ attribute.
4
4
  #
5
- # === Examples
5
+ # ==== Examples
6
6
  #
7
7
  # <%= turbo_frame_tag "tray", src: tray_path(tray) %>
8
8
  # # => <turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
@@ -24,19 +24,19 @@ module Turbo::FramesHelper
24
24
  # <% end %>
25
25
  # # => <turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
26
26
  #
27
- # The `turbo_frame_tag` helper will convert the arguments it receives to their
28
- # `dom_id` if applicable to easily generate unique ids for Turbo Frames:
27
+ # <%= turbo_frame_tag [user_id, "tray"], src: tray_path(tray) %>
28
+ # # => <turbo-frame id="1_tray" src="http://example.com/trays/1"></turbo-frame>
29
+ #
30
+ # The +turbo_frame_tag+ helper will convert the arguments it receives to their
31
+ # +dom_id+ if applicable to easily generate unique ids for Turbo Frames:
29
32
  #
30
33
  # <%= turbo_frame_tag(Article.find(1)) %>
31
34
  # # => <turbo-frame id="article_1"></turbo-frame>
32
35
  #
33
36
  # <%= turbo_frame_tag(Article.find(1), "comments") %>
34
- # # => <turbo-frame id="article_1_comments"></turbo-frame>
35
- #
36
- # <%= turbo_frame_tag(Article.find(1), Comment.new) %>
37
- # # => <turbo-frame id="article_1_new_comment"></turbo-frame>
37
+ # # => <turbo-frame id="comments_article_1"></turbo-frame>
38
38
  def turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)
39
- id = ids.first.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(*ids) : ids.first
39
+ id = ids.first.respond_to?(:to_key) ? ActionView::RecordIdentifier.dom_id(*ids) : ids.join('_')
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)
@@ -22,9 +22,8 @@ module Turbo::Streams::ActionHelper
22
22
  # message = Message.find(1)
23
23
  # turbo_stream_action_tag "remove", target: [message, :special]
24
24
  # # => <turbo-stream action="remove" target="special_message_1"></turbo-stream>
25
- #
26
25
  def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil, **attributes)
27
- template = action.to_sym == :remove ? "" : tag.template(template.to_s.html_safe)
26
+ template = action.to_sym.in?(%i[ remove refresh ]) ? "" : tag.template(template.to_s.html_safe)
28
27
 
29
28
  if target = convert_to_turbo_stream_dom_id(target)
30
29
  tag.turbo_stream(template, **attributes, action: action, target: target)
@@ -35,10 +34,19 @@ module Turbo::Streams::ActionHelper
35
34
  end
36
35
  end
37
36
 
37
+ # Creates a `turbo-stream` tag with an `action="refresh"` attribute. Example:
38
+ #
39
+ # turbo_stream_refresh_tag
40
+ # # => <turbo-stream action="refresh"></turbo-stream>
41
+ def turbo_stream_refresh_tag(request_id: Turbo.current_request_id, **attributes)
42
+ turbo_stream_action_tag(:refresh, **{ "request-id": request_id }.compact, **attributes)
43
+ end
44
+
38
45
  private
39
46
  def convert_to_turbo_stream_dom_id(target, include_selector: false)
40
- if Array(target).any? { |value| value.respond_to?(:to_key) }
41
- "#{"#" if include_selector}#{ActionView::RecordIdentifier.dom_id(*target)}"
47
+ target_array = Array.wrap(target)
48
+ if target_array.any? { |value| value.respond_to?(:to_key) }
49
+ "#{"#" if include_selector}#{ActionView::RecordIdentifier.dom_id(*target_array)}"
42
50
  else
43
51
  target
44
52
  end
@@ -49,7 +49,12 @@ module Turbo::StreamsHelper
49
49
  #
50
50
  # <%= turbo_stream_from "room", channel: RoomChannel, data: {room_name: "room #1"} %>
51
51
  #
52
+ # Raises an +ArgumentError+ if all streamables are blank
53
+ #
54
+ # <%= turbo_stream_from("") %> # => ArgumentError: streamables can't be blank
55
+ # <%= turbo_stream_from("", nil) %> # => ArgumentError: streamables can't be blank
52
56
  def turbo_stream_from(*streamables, **attributes)
57
+ raise ArgumentError, "streamables can't be blank" unless streamables.any?(&:present?)
53
58
  attributes[:channel] = attributes[:channel]&.to_s || "Turbo::StreamsChannel"
54
59
  attributes[:"signed-stream-name"] = Turbo::StreamsChannel.signed_stream_name(streamables)
55
60
 
@@ -8,4 +8,6 @@ export { cable }
8
8
 
9
9
  import { encodeMethodIntoRequestBody } from "./fetch_requests"
10
10
 
11
+ window.Turbo = Turbo
12
+
11
13
  addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody)
@@ -2,7 +2,7 @@
2
2
  class Turbo::Streams::ActionBroadcastJob < ActiveJob::Base
3
3
  discard_on ActiveJob::DeserializationError
4
4
 
5
- def perform(stream, action:, target:, **rendering)
6
- Turbo::StreamsChannel.broadcast_action_to stream, action: action, target: target, **rendering
5
+ def perform(stream, action:, target:, attributes: {}, **rendering)
6
+ Turbo::StreamsChannel.broadcast_action_to stream, action: action, target: target, attributes: attributes, **rendering
7
7
  end
8
8
  end
@@ -2,7 +2,7 @@
2
2
  # turbo stream templates.
3
3
  class Turbo::Streams::BroadcastJob < ActiveJob::Base
4
4
  discard_on ActiveJob::DeserializationError
5
-
5
+
6
6
  def perform(stream, **rendering)
7
7
  Turbo::StreamsChannel.broadcast_render_to stream, **rendering
8
8
  end
@@ -0,0 +1,7 @@
1
+ class Turbo::Streams::BroadcastStreamJob < ActiveJob::Base
2
+ discard_on ActiveJob::DeserializationError
3
+
4
+ def perform(stream, content:)
5
+ Turbo::StreamsChannel.broadcast_stream_to(stream, content: content)
6
+ end
7
+ end