Skip to content

Commit

Permalink
Turbo::Streams::TagBuilder can set targets attribute on stream tag
Browse files Browse the repository at this point in the history
hotwired/turbo#113 allowed for multiple targets in turbo frame streams. This attempts to expose the feature to the Rails turbo_stream tag helper. Instead of a `target` which results in a call to `document.getElementById` on the JS level, a `targets` attribute will be passed into `document.querySelectorAll`

I'm not 100% happy with the "API" to invoke it, but it's the best I could come up with. Feedback appreciated.

The gist of it is the turbo stream tag builder helpers now accept a `target_multiple` keyword arg (default `false`). When set to `true` the end result of the tag will have a `targets` attribute instead of a `target` attribute.

I purposfully did not touch the ActionCable-related part of the gem as I think its usecase is mostly geared to always updating one element at a time. Regardless, if it were to get this feature, I think it would be better in a separate pull request. For now, I think giving the developer the abiltiy to target multiple elements at once in the turbo stream response from a controller is ok for now.
  • Loading branch information
t27duck committed Jun 30, 2021
1 parent d89e1a0 commit 380bd56
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 36 deletions.
15 changes: 12 additions & 3 deletions app/helpers/turbo/streams/action_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ module Turbo::Streams::ActionHelper
#
# turbo_stream_action_tag "replace", target: "message_1", template: %(<div id="message_1">Hello!</div>)
# # => <turbo-stream action="replace" target="message_1"><template><div id="message_1">Hello!</div></template></turbo-stream>
def turbo_stream_action_tag(action, target:, template: nil)
target = convert_to_turbo_stream_dom_id(target)
#
# turbo_stream_action_tag "replace", targets: "message_1", template: %(<div id="message_1">Hello!</div>)
# # => <turbo-stream action="replace" targets="message_1"><template><div id="message_1">Hello!</div></template></turbo-stream>
def turbo_stream_action_tag(action, target: nil, targets: nil, template: nil)
template = action.to_sym == :remove ? "" : "<template>#{template}</template>"

%(<turbo-stream action="#{action}" target="#{target}">#{template}</turbo-stream>).html_safe
if target
target = convert_to_turbo_stream_dom_id(target)
%(<turbo-stream action="#{action}" target="#{target}">#{template}</turbo-stream>).html_safe
elsif targets
%(<turbo-stream action="#{action}" targets="#{targets}">#{template}</turbo-stream>).html_safe
else
raise ArgumentError, "target or targets must be supplied"
end
end

private
Expand Down
85 changes: 52 additions & 33 deletions app/models/turbo/streams/tag_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,108 +32,127 @@ def initialize(view_context)

# Removes the <tt>target</tt> from the dom. The target can either be a dom id string or an object that responds to
# <tt>to_key</tt>, which is then called and passed through <tt>ActionView::RecordIdentifier.dom_id</tt> (all Active Records
# do). Examples:
# do), or a CSS selector string (when using <tt>target_multiple: true</tt>). Examples:
#
# <%= turbo_stream.remove "clearance_5" %>
# <%= turbo_stream.remove clearance %>
def remove(target)
action :remove, target, allow_inferred_rendering: false
# <%= turbo_stream.remove ".invalid-row", target_multiple: true %>
def remove(target, target_multiple: false)
action :remove, target, allow_inferred_rendering: false, target_multiple: target_multiple
end

# Replace the <tt>target</tt> in the dom with the either the <tt>content</tt> passed in, a rendering result determined
# by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
# by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. To
# target multiple elements, pass <tt>target_multiple: true</tt> and set <tt>target</tt> to a CSS selector. Examples:
#
# <%= turbo_stream.replace "clearance_5", "<div id='clearance_5'>Replace the dom target identified by clearance_5</div>" %>
# <%= turbo_stream.replace clearance %>
# <%= turbo_stream.replace clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
# <%= turbo_stream.replace "clearance_5" do %>
# <div id='clearance_5'>Replace the dom target identified by clearance_5</div>
# <% end %>
def replace(target, content = nil, **rendering, &block)
action :replace, target, content, **rendering, &block
# <%= turbo_stream.replace ".original-records", target_multiple: true %>
def replace(target, content = nil, target_multiple: false, **rendering, &block)
action :replace, target, content, target_multiple: target_multiple, **rendering, &block
end

# Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
# the content in the block, or the rendering of the target as a record before the <tt>target</tt> in the dom. Examples:
# the content in the block, or the rendering of the target as a record before the <tt>target</tt> in the dom. To
# target multiple elements, pass <tt>target_multiple: true</tt> and set <tt>target</tt> to a CSS selector.Examples:
#
# <%= turbo_stream.before "clearance_5", "<div id='clearance_4'>Insert before the dom target identified by clearance_5</div>" %>
# <%= turbo_stream.before clearance %>
# <%= turbo_stream.before clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
# <%= turbo_stream.before "clearance_5" do %>
# <div id='clearance_4'>Insert before the dom target identified by clearance_5</div>
# <% end %>
def before(target, content = nil, **rendering, &block)
action :before, target, content, **rendering, &block
# <%= turbo_stream.before ".item", target_multiple: true, partial: "clearances/clearance_notification", locals: { title: "Hello" } %>
def before(target, content = nil, target_multiple: false, **rendering, &block)
action :before, target, content, target_multiple: target_multiple, **rendering, &block
end

# Insert the <tt>content</tt> passed in, a rendering result determined by the <tt>rendering</tt> keyword arguments,
# the content in the block, or the rendering of the target as a record after the <tt>target</tt> in the dom. Examples:
# the content in the block, or the rendering of the target as a record after the <tt>target</tt> in the dom. To
# target multiple elements, pass <tt>target_multiple: true</tt> and set <tt>target</tt> to a CSS selector. Examples:
#
# <%= turbo_stream.after "clearance_5", "<div id='clearance_6'>Insert after the dom target identified by clearance_5</div>" %>
# <%= turbo_stream.after clearance %>
# <%= turbo_stream.after clearance, partial: "clearances/clearance", locals: { title: "Hello" } %>
# <%= turbo_stream.after "clearance_5" do %>
# <div id='clearance_6'>Insert after the dom target identified by clearance_5</div>
# <% end %>
def after(target, content = nil, **rendering, &block)
action :after, target, content, **rendering, &block
# <%= turbo_stream.after ".item", target_multiple: true, partial: "clearances/clearance_notification", locals: { title: "Hello" } %>
def after(target, content = nil, target_multiple: false, **rendering, &block)
action :after, target, content, target_multiple: target_multiple, **rendering, &block
end

# Update the <tt>target</tt> in the dom with the either the <tt>content</tt> passed in or a rendering result determined
# by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. Examples:
# by the <tt>rendering</tt> keyword arguments, the content in the block, or the rendering of the target as a record. To
# target multiple elements, pass <tt>target_multiple: true</tt> and set <tt>target</tt> to a CSS selector. Examples:
#
# <%= turbo_stream.update "clearance_5", "Update the content of the dom target identified by clearance_5" %>
# <%= turbo_stream.update clearance %>
# <%= turbo_stream.update clearance, partial: "clearances/unique_clearance", locals: { title: "Hello" } %>
# <%= turbo_stream.update "clearance_5" do %>
# Update the content of the dom target identified by clearance_5
# <% end %>
def update(target, content = nil, **rendering, &block)
action :update, target, content, **rendering, &block
# <%= turbo_stream.update ".item", target_multiple: true, partial: "clearances/clearance_notification", locals: { title: "Hello" } %>
def update(target, content = nil, target_multiple: false, **rendering, &block)
action :update, target, content, target_multiple: target_multiple, **rendering, &block
end

# Append to the target in the dom identified with <tt>target</tt> either the <tt>content</tt> passed in or a
# rendering result determined by the <tt>rendering</tt> keyword arguments, the content in the block,
# or the rendering of the content as a record. Examples:
# or the rendering of the content as a record. To target multiple elements, pass <tt>target_multiple: true</tt> and
# set <tt>target</tt> to a CSS selector. Examples:
#
# <%= turbo_stream.append "clearances", "<div id='clearance_5'>Append this to .clearances</div>" %>
# <%= turbo_stream.append "clearances", clearance %>
# <%= turbo_stream.append "clearances", partial: "clearances/unique_clearance", locals: { clearance: clearance } %>
# <%= turbo_stream.append "clearances" do %>
# <div id='clearance_5'>Append this to .clearances</div>
# <% end %>
def append(target, content = nil, **rendering, &block)
action :append, target, content, **rendering, &block
# <%= turbo_stream.append ".item", target_multiple: true, partial: "clearances/clearance_notification", locals: { title: "Hello" } %>
def append(target, content = nil, target_multiple: false, **rendering, &block)
action :append, target, content, target_multiple: target_multiple, **rendering, &block
end

# Prepend to the target in the dom identified with <tt>target</tt> either the <tt>content</tt> passed in or a
# rendering result determined by the <tt>rendering</tt> keyword arguments or the content in the block,
# or the rendering of the content as a record. Examples:
# or the rendering of the content as a record. To target multiple elements, pass <tt>target_multiple: true</tt> and
# set <tt>target</tt> to a CSS selector. Examples:
#
# <%= turbo_stream.prepend "clearances", "<div id='clearance_5'>Prepend this to .clearances</div>" %>
# <%= turbo_stream.prepend "clearances", clearance %>
# <%= turbo_stream.prepend "clearances", partial: "clearances/unique_clearance", locals: { clearance: clearance } %>
# <%= turbo_stream.prepend "clearances" do %>
# <div id='clearance_5'>Prepend this to .clearances</div>
# <% end %>
def prepend(target, content = nil, **rendering, &block)
action :prepend, target, content, **rendering, &block
# <%= turbo_stream.prepend ".item", target_multiple: true, partial: "clearances/clearance_notification", locals: { title: "Hello" } %>
def prepend(target, content = nil, target_multiple: false, **rendering, &block)
action :prepend, target, content, target_multiple: target_multiple, **rendering, &block
end

# Send an action of the type <tt>name</tt>. Options described in the concrete methods.
def action(name, target, content = nil, allow_inferred_rendering: true, **rendering, &block)
target_name = extract_target_name_from(target)
def action(name, target_or_targets, content = nil, target_multiple: false, allow_inferred_rendering: true, **rendering, &block)
kwargs = if target_multiple
{ targets: extract_target_name_from(target_or_targets) }
else
{ target: extract_target_name_from(target_or_targets) }
end

case
when content
turbo_stream_action_tag name, target: target_name, template: (render_record(content) if allow_inferred_rendering) || content
when block_given?
turbo_stream_action_tag name, target: target_name, template: @view_context.capture(&block)
when rendering.any?
turbo_stream_action_tag name, target: target_name, template: @view_context.render(formats: [ :html ], **rendering)
else
turbo_stream_action_tag name, target: target_name, template: (render_record(target) if allow_inferred_rendering)
end
kwargs[:template] = case
when content
allow_inferred_rendering ? (render_record(content) || content) : content
when block_given?
@view_context.capture(&block)
when rendering.any?
@view_context.render(formats: [ :html ], **rendering)
else
render_record(target_or_targets) if allow_inferred_rendering
end

turbo_stream_action_tag name, **kwargs
end

private
Expand Down
4 changes: 4 additions & 0 deletions test/dummy/app/controllers/messages_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@ def create
format.turbo_stream { render turbo_stream: turbo_stream.append(:messages, "message_1"), status: :created }
end
end

def update
@message = Message.new(record_id: 1, content: "My message")
end
end
9 changes: 9 additions & 0 deletions test/dummy/app/views/messages/update.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<%= turbo_stream.remove @message, target_multiple: true %>
<%= turbo_stream.replace @message, target_multiple: true %>
<%= turbo_stream.replace @message, "Something else", target_multiple: true %>
<%= turbo_stream.replace "message_5", "Something fifth", target_multiple: true %>
<%= turbo_stream.replace "message_5", target_multiple: true, partial: "messages/message", locals: { message: Message.new(record_id: 5, content: "OLLA!") } %>
<%= turbo_stream.append "messages", @message, target_multiple: true %>
<%= turbo_stream.append "messages", target_multiple: true, partial: "messages/message", locals: { message: Message.new(record_id: 5, content: "OLLA!") } %>
<%= turbo_stream.prepend "messages", @message, target_multiple: true %>
<%= turbo_stream.prepend "messages", target_multiple: true, partial: "messages/message", locals: { message: Message.new(record_id: 5, content: "OLLA!") } %>
15 changes: 15 additions & 0 deletions test/streams/streams_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ class Turbo::StreamsControllerTest < ActionDispatch::IntegrationTest
STREAM
end

test "update all turbo actions for multiple targets" do
patch message_path(id: 1), as: :turbo_stream
assert_dom_equal <<~STREAM, @response.body
<turbo-stream action="remove" targets="message_1"></turbo-stream>
<turbo-stream action="replace" targets="message_1"><template><p>My message</p></template></turbo-stream>
<turbo-stream action="replace" targets="message_1"><template>Something else</template></turbo-stream>
<turbo-stream action="replace" targets="message_5"><template>Something fifth</template></turbo-stream>
<turbo-stream action="replace" targets="message_5"><template><p>OLLA!</p></template></turbo-stream>
<turbo-stream action="append" targets="messages"><template><p>My message</p></template></turbo-stream>
<turbo-stream action="append" targets="messages"><template><p>OLLA!</p></template></turbo-stream>
<turbo-stream action="prepend" targets="messages"><template><p>My message</p></template></turbo-stream>
<turbo-stream action="prepend" targets="messages"><template><p>OLLA!</p></template></turbo-stream>
STREAM
end

test "includes html format when rendering turbo_stream actions" do
post posts_path, as: :turbo_stream
assert_dom_equal <<~STREAM.chomp, @response.body
Expand Down

0 comments on commit 380bd56

Please sign in to comment.