turbo-rails 0.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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +24 -0
- data/.gitignore +2 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +147 -0
- data/MIT-LICENSE +20 -0
- data/README.md +66 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/turbo.js +3161 -0
- data/app/channels/turbo/streams/broadcasts.rb +66 -0
- data/app/channels/turbo/streams/stream_name.rb +24 -0
- data/app/channels/turbo/streams_channel.rb +17 -0
- data/app/controllers/turbo/frames/frame_request.rb +24 -0
- data/app/controllers/turbo/native/navigation.rb +49 -0
- data/app/controllers/turbo/native/navigation_controller.rb +13 -0
- data/app/controllers/turbo/streams/turbo_streams_tag_builder.rb +22 -0
- data/app/helpers/turbo/drive_helper.rb +16 -0
- data/app/helpers/turbo/frames_helper.rb +23 -0
- data/app/helpers/turbo/includes_helper.rb +5 -0
- data/app/helpers/turbo/streams/action_helper.rb +25 -0
- data/app/helpers/turbo/streams_helper.rb +22 -0
- data/app/javascript/turbo/cable.js +16 -0
- data/app/javascript/turbo/cable_stream_source_element.js +27 -0
- data/app/javascript/turbo/index.js +3 -0
- data/app/jobs/turbo/streams/action_broadcast_job.rb +6 -0
- data/app/jobs/turbo/streams/broadcast_job.rb +7 -0
- data/app/models/concerns/turbo/broadcastable.rb +236 -0
- data/app/models/turbo/streams/tag_builder.rb +127 -0
- data/config/routes.rb +6 -0
- data/lib/install/turbo.rb +11 -0
- data/lib/tasks/turbo_tasks.rake +6 -0
- data/lib/turbo-rails.rb +17 -0
- data/lib/turbo/engine.rb +65 -0
- data/lib/turbo/test_assertions.rb +22 -0
- data/lib/turbo/version.rb +3 -0
- data/package.json +42 -0
- data/rollup.config.js +23 -0
- data/test/drive/drive_helper_test.rb +8 -0
- data/test/dummy/.babelrc +18 -0
- data/test/dummy/.gitignore +3 -0
- data/test/dummy/.postcssrc.yml +3 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/config/manifest.js +2 -0
- data/test/dummy/app/assets/images/.keep +0 -0
- data/test/dummy/app/assets/stylesheets/application.css +15 -0
- data/test/dummy/app/assets/stylesheets/scaffold.css +80 -0
- data/test/dummy/app/channels/application_cable/channel.rb +4 -0
- data/test/dummy/app/channels/application_cable/connection.rb +4 -0
- data/test/dummy/app/controllers/application_controller.rb +2 -0
- data/test/dummy/app/controllers/concerns/.keep +0 -0
- data/test/dummy/app/controllers/messages_controller.rb +12 -0
- data/test/dummy/app/controllers/trays_controller.rb +17 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/javascript/packs/application.js +0 -0
- data/test/dummy/app/jobs/application_job.rb +2 -0
- data/test/dummy/app/mailboxes/application_mailbox.rb +2 -0
- data/test/dummy/app/mailboxes/messages_mailbox.rb +4 -0
- data/test/dummy/app/mailers/application_mailer.rb +4 -0
- data/test/dummy/app/models/application_record.rb +3 -0
- data/test/dummy/app/models/concerns/.keep +0 -0
- data/test/dummy/app/models/message.rb +29 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/test/dummy/app/views/messages/_message.html.erb +1 -0
- data/test/dummy/app/views/messages/_message.turbo_stream.erb +1 -0
- data/test/dummy/app/views/messages/show.turbo_stream.erb +9 -0
- data/test/dummy/app/views/trays/index.html.erb +3 -0
- data/test/dummy/app/views/trays/show.html.erb +3 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +36 -0
- data/test/dummy/bin/update +31 -0
- data/test/dummy/bin/yarn +11 -0
- data/test/dummy/config.ru +5 -0
- data/test/dummy/config/application.rb +22 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/cable.yml +10 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +34 -0
- data/test/dummy/config/environments/production.rb +96 -0
- data/test/dummy/config/environments/test.rb +38 -0
- data/test/dummy/config/initializers/application_controller_renderer.rb +8 -0
- data/test/dummy/config/initializers/assets.rb +14 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/content_security_policy.rb +22 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +5 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +33 -0
- data/test/dummy/config/puma.rb +34 -0
- data/test/dummy/config/routes.rb +4 -0
- data/test/dummy/config/spring.rb +6 -0
- data/test/dummy/config/webpack/development.js +3 -0
- data/test/dummy/config/webpack/environment.js +3 -0
- data/test/dummy/config/webpack/production.js +3 -0
- data/test/dummy/config/webpack/test.js +3 -0
- data/test/dummy/config/webpacker.yml +65 -0
- data/test/dummy/lib/assets/.keep +0 -0
- data/test/dummy/log/.keep +0 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
- data/test/dummy/public/apple-touch-icon.png +0 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/frames/frame_request_controller_test.rb +21 -0
- data/test/frames/frames_helper_test.rb +15 -0
- data/test/native/navigation_controller_test.rb +42 -0
- data/test/streams/broadcastable_test.rb +80 -0
- data/test/streams/streams_channel_test.rb +105 -0
- data/test/streams/streams_controller_test.rb +29 -0
- data/test/turbo_test.rb +10 -0
- data/turbo-rails.gemspec +16 -0
- data/yarn.lock +282 -0
- metadata +254 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Provides the broadcast actions in synchronous and asynchrous form for the <tt>Turbo::StreamsChannel</tt>.
|
|
2
|
+
# See <tt>Turbo::Broadcastable</tt> for the user-facing API that invokes these methods with most of the paperwork filled out already.
|
|
3
|
+
#
|
|
4
|
+
# Can be used directly using something like <tt>Turbo::StreamsChannel.broadcast_remove_to :entries, target: 1</tt>.
|
|
5
|
+
module Turbo::Streams::Broadcasts
|
|
6
|
+
include Turbo::Streams::ActionHelper
|
|
7
|
+
|
|
8
|
+
def broadcast_remove_to(*streamables, target:)
|
|
9
|
+
broadcast_action_to *streamables, action: :remove, target: target
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def broadcast_replace_to(*streamables, target:, **rendering)
|
|
13
|
+
broadcast_action_to *streamables, action: :replace, target: target, **rendering
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def broadcast_append_to(*streamables, target:, **rendering)
|
|
17
|
+
broadcast_action_to *streamables, action: :append, target: target, **rendering
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def broadcast_prepend_to(*streamables, target:, **rendering)
|
|
21
|
+
broadcast_action_to *streamables, action: :prepend, target: target, **rendering
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def broadcast_action_to(*streamables, action:, target:, **rendering)
|
|
25
|
+
broadcast_stream_to *streamables, content: turbo_stream_action_tag(action, target: target, template:
|
|
26
|
+
rendering.delete(:content) || (rendering.any? ? render_format(:html, **rendering) : nil)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def broadcast_replace_later_to(*streamables, target:, **rendering)
|
|
32
|
+
broadcast_action_later_to *streamables, action: :replace, target: target, **rendering
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def broadcast_append_later_to(*streamables, target:, **rendering)
|
|
36
|
+
broadcast_action_later_to *streamables, action: :append, target: target, **rendering
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def broadcast_prepend_later_to(*streamables, target:, **rendering)
|
|
40
|
+
broadcast_action_later_to *streamables, action: :prepend, target: target, **rendering
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def broadcast_action_later_to(*streamables, action:, target:, **rendering)
|
|
44
|
+
Turbo::Streams::ActionBroadcastJob.perform_later \
|
|
45
|
+
stream_name_from(streamables), action: action, target: target, **rendering
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def broadcast_render_to(*streamables, **rendering)
|
|
50
|
+
broadcast_stream_to *streamables, content: render_format(:turbo_stream, **rendering)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def broadcast_render_later_to(*streamables, **rendering)
|
|
54
|
+
Turbo::Streams::BroadcastJob.perform_later stream_name_from(streamables), **rendering
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def broadcast_stream_to(*streamables, content:)
|
|
58
|
+
ActionCable.server.broadcast stream_name_from(streamables), content
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def render_format(format, **rendering)
|
|
64
|
+
ApplicationController.render(formats: [ format ], **rendering)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Stream names are how we identify which updates should go to which users. All streams run over the same
|
|
2
|
+
# <tt>Turbo::StreamsChannel</tt>, but each with their own subscription. Since stream names are exposed directly to the user
|
|
3
|
+
# via the HTML stream subscription tags, we need to ensure that the name isn't tampered with, so the names are signed
|
|
4
|
+
# upon generation and verified upon receipt. All verification happens through the <tt>Turbo.signed_stream_verifier</tt>.
|
|
5
|
+
module Turbo::Streams::StreamName
|
|
6
|
+
# Used by <tt>Turbo::StreamsChannel</tt> to verify a signed stream name.
|
|
7
|
+
def verified_stream_name(signed_stream_name)
|
|
8
|
+
Turbo.signed_stream_verifier.verified signed_stream_name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Used by <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt> to generate a signed stream name.
|
|
12
|
+
def signed_stream_name(streamables)
|
|
13
|
+
Turbo.signed_stream_verifier.generate stream_name_from(streamables)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def stream_name_from(streamables)
|
|
18
|
+
if streamables.is_a?(Array)
|
|
19
|
+
streamables.map { |streamable| stream_name_from(streamable) }.join(":")
|
|
20
|
+
else
|
|
21
|
+
streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The streams channel delivers all the turbo-stream actions created (primarily) through <tt>Turbo::Broadcastable</tt>.
|
|
2
|
+
# A subscription to this channel is made for each individual stream that one wishes to listen for updates to.
|
|
3
|
+
# The subscription relies on being passed a <tt>signed_stream_name</tt> parameter generated by turning a set of streamables
|
|
4
|
+
# into signed stream name using <tt>Turbo::Streams::StreamName#signed_stream_name</tt>. This is automatically done
|
|
5
|
+
# using the view helper <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt>.
|
|
6
|
+
# If the signed stream name cannot be verified, the subscription is rejected.
|
|
7
|
+
class Turbo::StreamsChannel < ActionCable::Channel::Base
|
|
8
|
+
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
|
9
|
+
|
|
10
|
+
def subscribed
|
|
11
|
+
if verified_stream_name = self.class.verified_stream_name(params[:signed_stream_name])
|
|
12
|
+
stream_from verified_stream_name
|
|
13
|
+
else
|
|
14
|
+
reject
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Turbo frame requests are requests made from within a turbo frame with the intention of replacing the content of just
|
|
2
|
+
# that frame, not the whole page. They are automatically tagged as such by the Turbo Frame JavaScript, which adds a
|
|
3
|
+
# <tt>Turbo-Frame</tt> header to the request. When that header is detected by the controller, we ensure that any
|
|
4
|
+
# template layout is skipped (since we're only working on an in-page frame, thus can skip the weight of the layout), and
|
|
5
|
+
# that the etag for the page is changed (such that a cache for a layout-less request isn't served on a normal request
|
|
6
|
+
# and vice versa).
|
|
7
|
+
#
|
|
8
|
+
# This is merely a rendering optimization. Everything would still work just fine if we rendered everything including the layout.
|
|
9
|
+
# Turbo Frames knows how to fish out the relevant frame regardless.
|
|
10
|
+
#
|
|
11
|
+
# This module is automatically included in <tt>ActionController::Base</tt>.
|
|
12
|
+
module Turbo::Frames::FrameRequest
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
included do
|
|
16
|
+
layout -> { false if turbo_frame_request? }
|
|
17
|
+
etag { :frame if turbo_frame_request? }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
def turbo_frame_request?
|
|
22
|
+
request.headers["Turbo-Frame"].present?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Turbo is built to work with native navigation principles and present those alongside what's required for the web. When you
|
|
2
|
+
# have Turbo Native clients running (see the Turbo iOS and Turbo Android projects for details), you can respond to native
|
|
3
|
+
# requests with three dedicated responses: <tt>recede</tt>, <tt>resume</tt>, <tt>refresh</tt>.
|
|
4
|
+
#
|
|
5
|
+
# FIXME: Supply full description of when we use either.
|
|
6
|
+
module Turbo::Native::Navigation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def recede_or_redirect_to(url, **options)
|
|
10
|
+
turbo_native_action_or_redirect url, :recede, :to, options
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resume_or_redirect_to(url, **options)
|
|
14
|
+
turbo_native_action_or_redirect url, :resume, :to, options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def refresh_or_redirect_to(url, **options)
|
|
18
|
+
turbo_native_action_or_redirect url, :refresh, :to, options
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def recede_or_redirect_back_or_to(url, **options)
|
|
23
|
+
turbo_native_action_or_redirect url, :recede, :back, options
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resume_or_redirect_back_or_to(url, **options)
|
|
27
|
+
turbo_native_action_or_redirect url, :resume, :back, options
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def refresh_or_redirect_back_or_to(url, **options)
|
|
31
|
+
turbo_native_action_or_redirect url, :refresh, :back, options
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# :nodoc:
|
|
35
|
+
def turbo_native_action_or_redirect(url, action, redirect_type, options = {})
|
|
36
|
+
if turbo_native_app?
|
|
37
|
+
redirect_to send("turbo_#{action}_historical_location_url", notice: options[:notice] || options.delete(:native_notice))
|
|
38
|
+
elsif redirect_type == :back
|
|
39
|
+
redirect_back fallback_location: url, **options
|
|
40
|
+
else
|
|
41
|
+
redirect_to url, options
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Turbo Native applications are identified by having the string "Turbo Native" as part of their user agent.
|
|
46
|
+
def turbo_native_app?
|
|
47
|
+
request.user_agent.to_s.match?(/Turbo Native/)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Most turbo streams are rendered either asynchronously via <tt>Turbo::Broadcastable</tt>/<tt>Turbo::StreamsChannel</tt> or
|
|
2
|
+
# rendered in templates with the <tt>turbo_stream.erb</tt> extension. But it's also possible to render updates inline
|
|
3
|
+
# in controllers, like so:
|
|
4
|
+
#
|
|
5
|
+
# def destroy
|
|
6
|
+
# @user.destroy!
|
|
7
|
+
#
|
|
8
|
+
# respond_to do |format|
|
|
9
|
+
# format.turbo_stream { render turbo_stream: turbo_stream.remove(@user) }
|
|
10
|
+
# format.html { redirect_to users_url, notice: "User removed" }
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# This module adds that turbo_stream tag-builder object to all controllers. It's an instance of <tt>Turbo::Streams::TagBuilder</tt>
|
|
15
|
+
# instantiated with the current <tt>view_context</tt>.
|
|
16
|
+
module Turbo::Streams::TurboStreamsTagBuilder
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def turbo_stream
|
|
20
|
+
Turbo::Streams::TagBuilder.new(view_context)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Turbo::DriveHelper
|
|
2
|
+
# Pages that are more likely than not to be a cache miss can skip turbo cache to avoid visual jitter.
|
|
3
|
+
# Note: This requires a +yield :head+ provision in the application layout.
|
|
4
|
+
#
|
|
5
|
+
# ==== Example
|
|
6
|
+
#
|
|
7
|
+
# # app/views/application.html.erb
|
|
8
|
+
# <html><head><%= yield :head %></head><body><%= yield %></html>
|
|
9
|
+
#
|
|
10
|
+
# # app/views/trays/index.html.erb
|
|
11
|
+
# <% turbo_exempts_page_from_cache %>
|
|
12
|
+
# <p>Page that shouldn't be cached by Turbo</p>
|
|
13
|
+
def turbo_exempts_page_from_cache
|
|
14
|
+
provide :head, %(<meta name="turbo-cache-control" content="no-cache">).html_safe
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Turbo::FramesHelper
|
|
2
|
+
# Returns a frame tag that can either be used simply to encapsulate frame content or as a lazy-loading container that starts empty but
|
|
3
|
+
# fetches the URL supplied in the +src+ attribute.
|
|
4
|
+
#
|
|
5
|
+
# === Examples
|
|
6
|
+
#
|
|
7
|
+
# # => turbo-frame id="tray" src="http://example.com/trays/1"></turbo-frame>
|
|
8
|
+
# <%= turbo_frame_tag "tray", src: tray_path(tray) %>
|
|
9
|
+
#
|
|
10
|
+
# # => turbo-frame id="tray" links-target="top" src="http://example.com/trays/1"></turbo-frame>
|
|
11
|
+
# <%= turbo_frame_tag "tray", src: tray_path(tray), links_target: "top" %>
|
|
12
|
+
#
|
|
13
|
+
# # => turbo-frame id="tray" links-target="other_tray"></turbo-frame>
|
|
14
|
+
# <%= turbo_frame_tag "tray", links_target: "other_tray" %>
|
|
15
|
+
#
|
|
16
|
+
# # => turbo-frame id="tray"><div>My tray frame!</div></turbo-frame>
|
|
17
|
+
# <%= turbo_frame_tag "tray" do %>
|
|
18
|
+
# <div>My tray frame!</div>
|
|
19
|
+
# <% end %>
|
|
20
|
+
def turbo_frame_tag(id, src: nil, target: nil, **attributes, &block)
|
|
21
|
+
tag.turbo_frame(**attributes.merge(id: id, src: src, target: target).compact, &block)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Turbo::Streams::ActionHelper
|
|
2
|
+
# Creates a `turbo-stream` tag according to the passed parameters. Examples:
|
|
3
|
+
#
|
|
4
|
+
# # => <turbo-stream action="remove" target="message_1"></turbo-stream>
|
|
5
|
+
# turbo_stream_action_tag "remove", target: "message_1"
|
|
6
|
+
#
|
|
7
|
+
# # => <turbo-stream action="replace" target="message_1"><template><div id="message_1">Hello!</div></template></turbo-stream>
|
|
8
|
+
# turbo_stream_action_tag "replace", target: "message_1", template: %(<div id="message_1">Hello!</div>)
|
|
9
|
+
def turbo_stream_action_tag(action, target:, template: nil)
|
|
10
|
+
target = convert_to_turbo_stream_dom_id(target)
|
|
11
|
+
template = template ? "<template>#{template}</template>" : ""
|
|
12
|
+
|
|
13
|
+
%(<turbo-stream action="#{action}" target="#{target}">#{template}</turbo-stream>).html_safe
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def convert_to_turbo_stream_dom_id(element_or_dom_id)
|
|
18
|
+
if element_or_dom_id.respond_to?(:to_key)
|
|
19
|
+
element = element_or_dom_id
|
|
20
|
+
ActionView::RecordIdentifier.dom_id(element)
|
|
21
|
+
else
|
|
22
|
+
dom_id = element_or_dom_id
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Turbo::StreamsHelper
|
|
2
|
+
# Returns a new <tt>Turbo::Streams::TagBuilder</tt> object that accepts stream actions and renders them them as
|
|
3
|
+
# the template tags needed to send across the wire. This object is automatically yielded to turbo_stream.erb templates.
|
|
4
|
+
def turbo_stream
|
|
5
|
+
Turbo::Streams::TagBuilder.new(self)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Used in the view to create a subscription to a stream identified by the <tt>streamables</tt> running over the
|
|
9
|
+
# <tt>Turbo::StreamsChannel</tt>. The stream name being generated is safe to embed in the HTML sent to a user without
|
|
10
|
+
# fear of tampering, as it is signed using <tt>Turbo.signed_stream_verifier</tt>. Example:
|
|
11
|
+
#
|
|
12
|
+
# # app/views/entries/index.html.erb
|
|
13
|
+
# <%= turbo_stream_from Current.account, :entries %>
|
|
14
|
+
# <div id="entries">New entries will be appended to this container</div>
|
|
15
|
+
#
|
|
16
|
+
# The example above will process all turbo streams sent to a stream name like <tt>account:5:entries</tt>
|
|
17
|
+
# (when Current.account.id = 5). Updates to this stream can be sent like
|
|
18
|
+
# <tt>entry.broadcast_append_to entry.account, :entries, contrainer: "entries"</tt>.
|
|
19
|
+
def turbo_stream_from(*streamables)
|
|
20
|
+
tag.turbo_cable_stream_source channel: "Turbo::StreamsChannel", "signed-stream-name": Turbo::StreamsChannel.signed_stream_name(streamables)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
let consumer
|
|
2
|
+
|
|
3
|
+
export async function getConsumer() {
|
|
4
|
+
if (consumer) return consumer
|
|
5
|
+
const { createConsumer } = await import("@rails/actioncable/src")
|
|
6
|
+
return setConsumer(createConsumer())
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function setConsumer(newConsumer) {
|
|
10
|
+
return consumer = newConsumer
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function subscribeTo(channel, mixin) {
|
|
14
|
+
const { subscriptions } = await getConsumer()
|
|
15
|
+
return subscriptions.create(channel, mixin)
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
|
|
2
|
+
import { subscribeTo } from "./cable"
|
|
3
|
+
|
|
4
|
+
class TurboCableStreamSourceElement extends HTMLElement {
|
|
5
|
+
async connectedCallback() {
|
|
6
|
+
connectStreamSource(this)
|
|
7
|
+
this.subscription = subscribeTo(this.channel, { received: this.dispatchMessageEvent.bind(this) })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
disconnectedCallback() {
|
|
11
|
+
disconnectStreamSource(this)
|
|
12
|
+
if (this.subscription) this.subscription.unsubscribe()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
dispatchMessageEvent(data) {
|
|
16
|
+
const event = new MessageEvent("message", { data })
|
|
17
|
+
return this.dispatchEvent(event)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get channel() {
|
|
21
|
+
const channel = this.getAttribute("channel")
|
|
22
|
+
const signed_stream_name = this.getAttribute("signed-stream-name")
|
|
23
|
+
return { channel, signed_stream_name }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# The job that powers all the <tt>broadcast_$action_later</tt> broadcasts available in <tt>Turbo::Streams::Broadcasts</tt>.
|
|
2
|
+
class Turbo::Streams::ActionBroadcastJob < ActiveJob::Base
|
|
3
|
+
def perform(stream, action:, target:, **rendering)
|
|
4
|
+
Turbo::StreamsChannel.broadcast_action_to stream, action: action, target: target, **rendering
|
|
5
|
+
end
|
|
6
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The job that powers the <tt>broadcast_render_later_to</tt> available in <tt>Turbo::Streams::Broadcasts</tt> for rendering
|
|
2
|
+
# turbo stream templates.
|
|
3
|
+
class Turbo::Streams::BroadcastJob < ActiveJob::Base
|
|
4
|
+
def perform(stream, **rendering)
|
|
5
|
+
Turbo::StreamsChannel.broadcast_render_to stream, **rendering
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Turbo streams can be broadcast directly from models that include this module (this is automatically done for Active Records).
|
|
2
|
+
# This makes it convenient to execute both synchronous and asynchronous updates, and render directly from callbacks in models
|
|
3
|
+
# or from controllers or jobs that act on those models. Here's an example:
|
|
4
|
+
#
|
|
5
|
+
# class Clearance < ApplicationRecord
|
|
6
|
+
# belongs_to :petitioner, class_name: "Contact"
|
|
7
|
+
# belongs_to :examiner, class_name: "User"
|
|
8
|
+
#
|
|
9
|
+
# after_create_commit :broadcast_later
|
|
10
|
+
#
|
|
11
|
+
# private
|
|
12
|
+
# def broadcast_later
|
|
13
|
+
# broadcast_prepend_later_to examiner.identity, :clearances
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# This is an example from [HEY](https://hey.com), and the clearance is the model that drives
|
|
18
|
+
# [the screener](https://hey.com/features/the-screener/), which gives users the power to deny first-time senders (petitioners)
|
|
19
|
+
# access to their attention (as the examiner). When a new clearance is created upon receipt of an email from a first-time
|
|
20
|
+
# sender, that'll trigger the call to broadcast_later, which in turn invokes <tt>broadcast_prepend_later_to</tt>.
|
|
21
|
+
#
|
|
22
|
+
# That method enqueues a <tt>Turbo::Streams::ActionBroadcastJob</tt> for the prepend, which will render the partial for clearance
|
|
23
|
+
# (it knows which by calling Clearance#to_partial_path, which in this case returns <tt>clearances/_clearance.html.erb</tt>),
|
|
24
|
+
# send that to all users that have subscribed to updates (using <tt>turbo_stream_from(examiner.identity, :clearances)</tt> in a view)
|
|
25
|
+
# using the <tt>Turbo::StreamsChannel</tt> under the stream name derived from <tt>[ examiner.identity, :clearances ]</tt>,
|
|
26
|
+
# and finally prepend the result of that partial rendering to the target identified with the dom id "clearances"
|
|
27
|
+
# (which is derived by default from the plural model name of the model, but can be overwritten).
|
|
28
|
+
#
|
|
29
|
+
# There are four basic actions you can broadcast: <tt>remove</tt>, <tt>replace</tt>, <tt>append</tt>, and
|
|
30
|
+
# <tt>prepend</tt>. As a rule, you should use the <tt>_later</tt> versions of everything except for remove when broadcasting
|
|
31
|
+
# within a real-time path, like a controller or model, since all those updates require a rendering step, which can slow down
|
|
32
|
+
# execution. You don't need to do this for remove, since only the dom id for the model is used.
|
|
33
|
+
#
|
|
34
|
+
# In addition to the four basic actions, you can also use <tt>broadcast_render_later</tt> or
|
|
35
|
+
# <tt>broadcast_render_later_to</tt> to render a turbo stream template with multiple actions.
|
|
36
|
+
module Turbo::Broadcastable
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
module ClassMethods
|
|
40
|
+
# Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
|
|
41
|
+
# <tt>stream</tt> symbol invocation. By default, the creates are appended to a dom id target name derived from
|
|
42
|
+
# the model's plural name. The insertion can also be made to be a prepend by overwriting <tt>insertion</tt> and
|
|
43
|
+
# the target dom id overwritten by passing <tt>target</tt>. Examples:
|
|
44
|
+
#
|
|
45
|
+
# class Message < ApplicationRecord
|
|
46
|
+
# belongs_to :board
|
|
47
|
+
# broadcasts_to :board
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# class Message < ApplicationRecord
|
|
51
|
+
# belongs_to :board
|
|
52
|
+
# broadcasts_to ->(message) { [ message.board, :messages ] }, inserts_by: :prepend, target: "board_messages"
|
|
53
|
+
# end
|
|
54
|
+
def broadcasts_to(stream, inserts_by: :append, target: model_name.plural)
|
|
55
|
+
after_create_commit -> { broadcast_action_later_to stream.try(:call, self) || send(stream), action: inserts_by, target: target }
|
|
56
|
+
after_update_commit -> { broadcast_replace_later_to stream.try(:call, self) || send(stream) }
|
|
57
|
+
after_destroy_commit -> { broadcast_remove_to stream.try(:call, self) || send(stream) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Same as <tt>#broadcasts_to</tt>, but the designated stream is automatically set to the current model.
|
|
61
|
+
def broadcasts(inserts_by: :append, target: model_name.plural)
|
|
62
|
+
after_create_commit -> { broadcast_action_later action: inserts_by, target: target }
|
|
63
|
+
after_update_commit -> { broadcast_replace_later }
|
|
64
|
+
after_destroy_commit -> { broadcast_remove }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Remove this broadcastable model from the dom for subscribers of the stream name identified by the passed streamables.
|
|
69
|
+
# Example:
|
|
70
|
+
#
|
|
71
|
+
# # Sends <turbo-stream action="remove" target="clearance_5"></turbo-stream> to the stream named "identity:2:clearances"
|
|
72
|
+
# clearance.broadcast_remove_to examiner.identity, :clearances
|
|
73
|
+
def broadcast_remove_to(*streamables)
|
|
74
|
+
Turbo::StreamsChannel.broadcast_remove_to *streamables, target: self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Same as <tt>#broadcast_remove_to</tt>, but the designated stream is automatically set to the current model.
|
|
78
|
+
def broadcast_remove
|
|
79
|
+
broadcast_remove_to self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Replace this broadcastable model in the dom for subscribers of the stream name identified by the passed
|
|
83
|
+
# <tt>streamables</tt>. The rendering parameters can be set by appending named arguments to the call. Examples:
|
|
84
|
+
#
|
|
85
|
+
# # Sends <turbo-stream action="replace" target="clearance_5"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
|
|
86
|
+
# # to the stream named "identity:2:clearances"
|
|
87
|
+
# clearance.broadcast_replace_to examiner.identity, :clearances
|
|
88
|
+
#
|
|
89
|
+
# # Sends <turbo-stream action="replace" target="clearance_5"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
|
|
90
|
+
# # to the stream named "identity:2:clearances"
|
|
91
|
+
# clearance.broadcast_replace_to examiner.identity, :clearances, partial: "clearances/other_partial", locals: { a: 1 }
|
|
92
|
+
def broadcast_replace_to(*streamables, **rendering)
|
|
93
|
+
Turbo::StreamsChannel.broadcast_replace_to *streamables, target: self, **broadcast_rendering_with_defaults(rendering)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Same as <tt>#broadcast_replace_to</tt>, but the designated stream is automatically set to the current model.
|
|
97
|
+
def broadcast_replace(**rendering)
|
|
98
|
+
broadcast_replace_to self, **rendering
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Append a rendering of this broadcastable model to the target identified by it's dom id passed as <tt>target</tt>
|
|
102
|
+
# for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
|
|
103
|
+
# appending named arguments to the call. Examples:
|
|
104
|
+
#
|
|
105
|
+
# # Sends <turbo-stream action="append" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
|
|
106
|
+
# # to the stream named "identity:2:clearances"
|
|
107
|
+
# clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances"
|
|
108
|
+
#
|
|
109
|
+
# # Sends <turbo-stream action="append" target="clearances"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
|
|
110
|
+
# # to the stream named "identity:2:clearances"
|
|
111
|
+
# clearance.broadcast_append_to examiner.identity, :clearances, target: "clearances",
|
|
112
|
+
# partial: "clearances/other_partial", locals: { a: 1 }
|
|
113
|
+
def broadcast_append_to(*streamables, target: broadcast_target_default, **rendering)
|
|
114
|
+
Turbo::StreamsChannel.broadcast_append_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
|
|
118
|
+
def broadcast_append(target: broadcast_target_default, **rendering)
|
|
119
|
+
broadcast_append_to self, target: target, **rendering
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Prepend a rendering of this broadcastable model to the target identified by it's dom id passed as <tt>target</tt>
|
|
123
|
+
# for subscribers of the stream name identified by the passed <tt>streamables</tt>. The rendering parameters can be set by
|
|
124
|
+
# appending named arguments to the call. Examples:
|
|
125
|
+
#
|
|
126
|
+
# # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
|
|
127
|
+
# # to the stream named "identity:2:clearances"
|
|
128
|
+
# clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances"
|
|
129
|
+
#
|
|
130
|
+
# # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">Other partial</div></template></turbo-stream>
|
|
131
|
+
# # to the stream named "identity:2:clearances"
|
|
132
|
+
# clearance.broadcast_prepend_to examiner.identity, :clearances, target: "clearances",
|
|
133
|
+
# partial: "clearances/other_partial", locals: { a: 1 }
|
|
134
|
+
def broadcast_prepend_to(*streamables, target: broadcast_target_default, **rendering)
|
|
135
|
+
Turbo::StreamsChannel.broadcast_prepend_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Same as <tt>#broadcast_append_to</tt>, but the designated stream is automatically set to the current model.
|
|
139
|
+
def broadcast_prepend(target: broadcast_target_default, **rendering)
|
|
140
|
+
broadcast_prepend_to self, target: target, **rendering
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Broadcast a named <tt>action</tt>, allowing for dynamic dispatch, instead of using the concrete action methods. Examples:
|
|
144
|
+
#
|
|
145
|
+
# # Sends <turbo-stream action="prepend" target="clearances"><template><div id="clearance_5">My Clearance</div></template></turbo-stream>
|
|
146
|
+
# # to the stream named "identity:2:clearances"
|
|
147
|
+
# clearance.broadcast_action_to examiner.identity, :clearances, action: :prepend, target: "clearances"
|
|
148
|
+
def broadcast_action_to(*streamables, action:, target: broadcast_target_default, **rendering)
|
|
149
|
+
Turbo::StreamsChannel.broadcast_action_to(*streamables, action: action, target: target, **broadcast_rendering_with_defaults(rendering))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Same as <tt>#broadcast_action_to</tt>, but the designated stream is automatically set to the current model.
|
|
153
|
+
def broadcast_action(action, target: broadcast_target_default, **rendering)
|
|
154
|
+
broadcast_action_to self, action: action, target: target, **rendering
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Same as <tt>broadcast_replace_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
|
|
159
|
+
def broadcast_replace_later_to(*streamables, **rendering)
|
|
160
|
+
Turbo::StreamsChannel.broadcast_replace_later_to *streamables, target: self, **broadcast_rendering_with_defaults(rendering)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Same as <tt>#broadcast_replace_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
164
|
+
def broadcast_replace_later(**rendering)
|
|
165
|
+
broadcast_replace_later_to self, **rendering
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Same as <tt>broadcast_append_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
|
|
169
|
+
def broadcast_append_later_to(*streamables, target: broadcast_target_default, **rendering)
|
|
170
|
+
Turbo::StreamsChannel.broadcast_append_later_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Same as <tt>#broadcast_append_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
174
|
+
def broadcast_append_later(target: broadcast_target_default, **rendering)
|
|
175
|
+
broadcast_append_later_to self, target: target, **rendering
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Same as <tt>broadcast_prepend_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
|
|
179
|
+
def broadcast_prepend_later_to(*streamables, target: broadcast_target_default, **rendering)
|
|
180
|
+
Turbo::StreamsChannel.broadcast_prepend_later_to *streamables, target: target, **broadcast_rendering_with_defaults(rendering)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Same as <tt>#broadcast_prepend_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
184
|
+
def broadcast_prepend_later(target: broadcast_target_default, **rendering)
|
|
185
|
+
broadcast_prepend_later_to self, target: target, **rendering
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Same as <tt>broadcast_action_later_to</tt> but run asynchronously via a <tt>Turbo::Streams::BroadcastJob</tt>.
|
|
189
|
+
def broadcast_action_later_to(*streamables, action:, target: broadcast_target_default, **rendering)
|
|
190
|
+
Turbo::StreamsChannel.broadcast_action_later_to(*streamables, action: action, target: target, **broadcast_rendering_with_defaults(rendering))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Same as <tt>#broadcast_action_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
194
|
+
def broadcast_action_later(action:, target: broadcast_target_default, **rendering)
|
|
195
|
+
broadcast_action_later_to self, action: action, target: target, **rendering
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# Render a turbo stream template asynchronously with this broadcastable model passed as the local variable using a
|
|
200
|
+
# <tt>Turbo::Streams::BroadcastJob</tt>. Example:
|
|
201
|
+
#
|
|
202
|
+
# # Template: entries/_entry.turbo_stream.erb
|
|
203
|
+
# <%= turbo_stream.remove entry %>
|
|
204
|
+
#
|
|
205
|
+
# <%= turbo_stream.append "entries" do %>
|
|
206
|
+
# <%= render partial: "entries/entry", locals: { entry: entry }, formats: [ :html ] %>
|
|
207
|
+
# <% end if entry.active? %>
|
|
208
|
+
#
|
|
209
|
+
# # Sends:
|
|
210
|
+
# # <turbo-stream action="remove" target="entry_5"></turbo-stream>
|
|
211
|
+
# # <turbo-stream action="append" target="entries"><template><div id="entry_5">My Entry</div></template></turbo-stream>
|
|
212
|
+
# # to the stream named "entry:5"
|
|
213
|
+
# entry.broadcast_render_later
|
|
214
|
+
def broadcast_render_later(**rendering)
|
|
215
|
+
broadcast_render_later_to self, **rendering
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Same as <tt>broadcast_prepend_to</tt> but run with the added option of naming the stream using the passed
|
|
219
|
+
# <tt>streamables</tt>.
|
|
220
|
+
def broadcast_render_later_to(*streamables, **rendering)
|
|
221
|
+
Turbo::StreamsChannel.broadcast_render_later_to *streamables, **broadcast_rendering_with_defaults(rendering)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
def broadcast_target_default
|
|
227
|
+
model_name.plural
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def broadcast_rendering_with_defaults(options)
|
|
231
|
+
options.tap do |o|
|
|
232
|
+
o[:locals] = (o[:locals] || {}).reverse_merge!(model_name.singular.to_sym => self)
|
|
233
|
+
o[:partial] ||= to_partial_path
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|