From b5126864a81a4b9dd853a133ef03f42a89ba8bb3 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 20 Aug 2022 19:00:40 +0200 Subject: [PATCH 1/3] Introduce `CableReady.config.operation_mode` Option to output CableReady operations to the Turbo Streams wire format --- Gemfile.lock | 5 + cable_ready.gemspec | 1 + lib/cable_ready.rb | 13 ++ lib/cable_ready/config.rb | 3 +- lib/cable_ready/operation_builder.rb | 47 ++++- .../config/initializers/cable_ready.rb | 5 + .../operation_builder_turbo_stream_test.rb | 194 ++++++++++++++++++ 7 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 test/lib/cable_ready/operation_builder_turbo_stream_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index 68884406..78b0c49e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,6 +9,7 @@ PATH activesupport (>= 5.2) railties (>= 5.2) thread-local (>= 1.1.0) + turbo-rails GEM remote: https://rubygems.org/ @@ -170,6 +171,10 @@ GEM standard thor (1.1.0) thread-local (1.1.0) + turbo-rails (1.1.1) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) unicode-display_width (2.1.0) diff --git a/cable_ready.gemspec b/cable_ready.gemspec index 444a4ebf..c3d7ea4b 100644 --- a/cable_ready.gemspec +++ b/cable_ready.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |gem| gem.add_dependency "activerecord", rails_version gem.add_dependency "activesupport", rails_version gem.add_dependency "railties", rails_version + gem.add_dependency "turbo-rails" gem.add_dependency "thread-local", ">= 1.1.0" diff --git a/lib/cable_ready.rb b/lib/cable_ready.rb index dac428b5..c9ee2399 100644 --- a/lib/cable_ready.rb +++ b/lib/cable_ready.rb @@ -21,6 +21,19 @@ require "cable_ready/cable_car" require "cable_ready/stream_identifier" +require "turbo-rails" +turbo = Gem::Specification.find_by_name("turbo-rails").gem_dir + +module Turbo + module Streams + end +end + +require "#{turbo}/app/helpers/turbo/streams/action_helper" +require "#{turbo}/app/models/turbo/streams/tag_builder" +require "#{turbo}/app/helpers/turbo/streams_helper" + + module CableReady class << self def config diff --git a/lib/cable_ready/config.rb b/lib/cable_ready/config.rb index fd8ac6ce..bcdf0020 100644 --- a/lib/cable_ready/config.rb +++ b/lib/cable_ready/config.rb @@ -7,7 +7,7 @@ class Config include Observable include Singleton - attr_accessor :on_failed_sanity_checks, :on_new_version_available + attr_accessor :on_failed_sanity_checks, :on_new_version_available, :operation_mode attr_writer :verifier_key def initialize @@ -15,6 +15,7 @@ def initialize @operation_names = Set.new(default_operation_names) @on_failed_sanity_checks = :exit @on_new_version_available = :ignore + @operation_mode = :cable_ready end def observers diff --git a/lib/cable_ready/operation_builder.rb b/lib/cable_ready/operation_builder.rb index b2d0f68f..d1fbaa3a 100644 --- a/lib/cable_ready/operation_builder.rb +++ b/lib/cable_ready/operation_builder.rb @@ -3,6 +3,8 @@ module CableReady class OperationBuilder include Identifiable + include Turbo::Streams::ActionHelper + attr_reader :identifier, :previous_selector def self.finalizer_for(identifier) @@ -58,6 +60,14 @@ def to_json(*args) @enqueued_operations.to_json(*args) end + def to_turbo_stream + operations_payload.join + end + + def to_html + to_turbo_stream + end + def apply!(operations = "[]") operations = begin JSON.parse(operations.is_a?(String) ? operations : operations.to_json) @@ -69,7 +79,42 @@ def apply!(operations = "[]") end def operations_payload - @enqueued_operations.map { |operation| operation.deep_transform_keys! { |key| key.to_s.camelize(:lower) } } + if ::CableReady.config.operation_mode == :turbo_stream + def translate_operation_name(name) + case name + when "innerHtml" then "replace" + else name + end + end + + def single_selector?(selector) + selector.starts_with?("#") + end + + def translate_selector(operation) + dom_id = operation["domId"] || operation[:dom_id] || operation["dom_id"] + return [dom_id, :target] if dom_id.present? + + selector = operation["selector"] + return ["body", :targets] if selector.nil? || selector.empty? + + if single_selector?(selector) + return [selector.from(1), :target] + end + + [selector, :targets] + end + + @enqueued_operations.map do |operation| + turbo_action = translate_operation_name(operation["operation"]) + turbo_target, target_attribute = translate_selector(operation) + turbo_template = operation["html"] || operation[:html] || operation["message"] + + turbo_stream_action_tag(turbo_action, target_attribute => turbo_target, template: turbo_template) + end + else + @enqueued_operations.map { |operation| operation.deep_transform_keys! { |key| key.to_s.camelize(:lower) } } + end end def operations_in_custom_element diff --git a/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb b/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb index 0375cba0..2a80d60c 100644 --- a/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb +++ b/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb @@ -11,6 +11,11 @@ # config.on_new_version_available = :ignore + # Specify operations payload output format, options: + # `:cable_ready` or `:turbo_stream` + + # config.operation_mode = :turbo_stream + # Define your own custom operations # https://cableready.stimulusreflex.com/customization#custom-operations diff --git a/test/lib/cable_ready/operation_builder_turbo_stream_test.rb b/test/lib/cable_ready/operation_builder_turbo_stream_test.rb new file mode 100644 index 00000000..9a145bbf --- /dev/null +++ b/test/lib/cable_ready/operation_builder_turbo_stream_test.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../lib/cable_ready" + +class Death + def to_html + "I rock" + end + + def to_dom_id + "death" + end + + def to_operation_options + [:html, :dom_id, :spaz] + end +end + +class Life + def to_operation_options + { + html: "You go, girl", + dom_id: "life" + } + end +end + +class CableReady::OperationBuilderTurboStreamTest < ActiveSupport::TestCase + setup do + CableReady.config.operation_mode = :turbo_stream + @operation_builder = CableReady::OperationBuilder.new("test") + end + + teardown do + CableReady.config.operation_mode = :cable_ready + end + + test "should create enqueued operations" do + assert_not_nil @operation_builder.instance_variable_get(:@enqueued_operations) + end + + test "should add observer to cable ready" do + assert_not_nil CableReady.config.instance_variable_get(:@observer_peers)[@operation_builder] + end + + test "should remove observer when destroyed" do + @operation_builder = nil + assert_nil CableReady.config.instance_variable_get(:@observer_peers)[@operation_builder] + end + + test "should add operation method" do + @operation_builder.add_operation_method("foobar") + assert @operation_builder.respond_to?(:foobar) + end + + test "should operations convert operations to Turbo Stream / HTML" do + @operation_builder.add_operation_method("foobar") + @operation_builder.foobar({name: "passed_option"}) + + # the Turbo Helper current don't supoport custom additional attributes + # assert_equal("", @operation_builder.to_turbo_stream) + # assert_equal("", @operation_builder.to_html) + + assert_equal("", @operation_builder.to_turbo_stream) + assert_equal("", @operation_builder.to_html) + end + + test "operations payload should omit empty operations" do + @operation_builder.add_operation_method("foobar") + assert_equal([], @operation_builder.operations_payload) + assert_equal("", @operation_builder.to_html) + assert_equal("", @operation_builder.to_turbo_stream) + end + + test "operations payload should camelize keys" do + @operation_builder.add_operation_method("foo_bar") + @operation_builder.foo_bar({beep_boop: "passed_option"}) + + # the Turbo Helper current don't supoport custom additional attributes + # operations = [""] + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should take first argument as selector" do + @operation_builder.add_operation_method("inner_html") + + @operation_builder.inner_html("#smelly", html: "I rock") + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should use previously passed selector in next operation" do + @operation_builder.add_operation_method("inner_html") + @operation_builder.add_operation_method("set_focus") + + @operation_builder.set_focus("#smelly").inner_html(html: "I rock") + + operations = [ + "", + "" + ] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should clear previous_selector after calling reset!" do + @operation_builder.add_operation_method("inner_html") + @operation_builder.inner_html(selector: "#smelly", html: "I rock") + + @operation_builder.reset! + + @operation_builder.inner_html(html: "winning") + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should use previous_selector if present and should use `selector` if explicitly provided" do + @operation_builder.add_operation_method("inner_html") + @operation_builder.add_operation_method("set_focus") + + @operation_builder.set_focus("#smelly").inner_html(html: "I rock").inner_html(html: "I rock too", selector: "#smelly2") + + operations = [ + "", + "", + "", + ] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should pull html option from Death object" do + @operation_builder.add_operation_method("inner_html") + death = Death.new + + @operation_builder.inner_html(html: death) + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should pull html option with selector from Death object" do + @operation_builder.add_operation_method("inner_html") + death = Death.new + + @operation_builder.inner_html(death, html: death) + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should pull html and dom_id options from Death object" do + @operation_builder.add_operation_method("inner_html") + death = Death.new + + @operation_builder.inner_html(death) + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should pull html and dom_id options from Life object" do + @operation_builder.add_operation_method("inner_html") + life = Life.new + + @operation_builder.inner_html(life) + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end + + test "should put operation[message] into the tempalte tag" do + @operation_builder.add_operation_method("console_log") + + @operation_builder.console_log(message: "Hello Console", level: "warn") + + # the Turbo Helper current don't supoport custom additional attributes + # operations = [""] + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end +end From fc297c9371ff9ece2d68b5c6b14870d91865c031 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 20 Aug 2022 22:50:45 +0200 Subject: [PATCH 2/3] Pass along remaining attributes as attributes for `` element --- lib/cable_ready/operation_builder.rb | 5 +++-- .../operation_builder_turbo_stream_test.rb | 16 ++++------------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/lib/cable_ready/operation_builder.rb b/lib/cable_ready/operation_builder.rb index d1fbaa3a..b2db3a08 100644 --- a/lib/cable_ready/operation_builder.rb +++ b/lib/cable_ready/operation_builder.rb @@ -108,9 +108,10 @@ def translate_selector(operation) @enqueued_operations.map do |operation| turbo_action = translate_operation_name(operation["operation"]) turbo_target, target_attribute = translate_selector(operation) - turbo_template = operation["html"] || operation[:html] || operation["message"] + turbo_template = operation["html"] || operation[:html] + attributes = operation.except("operation", "selector", "html", "domId", "dom_id", :dom_id, :html).deep_transform_keys { |key| key.to_s.dasherize } - turbo_stream_action_tag(turbo_action, target_attribute => turbo_target, template: turbo_template) + turbo_stream_action_tag(turbo_action, target_attribute => turbo_target, template: turbo_template, **attributes) end else @enqueued_operations.map { |operation| operation.deep_transform_keys! { |key| key.to_s.camelize(:lower) } } diff --git a/test/lib/cable_ready/operation_builder_turbo_stream_test.rb b/test/lib/cable_ready/operation_builder_turbo_stream_test.rb index 9a145bbf..ad2d7237 100644 --- a/test/lib/cable_ready/operation_builder_turbo_stream_test.rb +++ b/test/lib/cable_ready/operation_builder_turbo_stream_test.rb @@ -58,12 +58,8 @@ class CableReady::OperationBuilderTurboStreamTest < ActiveSupport::TestCase @operation_builder.add_operation_method("foobar") @operation_builder.foobar({name: "passed_option"}) - # the Turbo Helper current don't supoport custom additional attributes - # assert_equal("", @operation_builder.to_turbo_stream) - # assert_equal("", @operation_builder.to_html) - - assert_equal("", @operation_builder.to_turbo_stream) - assert_equal("", @operation_builder.to_html) + assert_equal("", @operation_builder.to_turbo_stream) + assert_equal("", @operation_builder.to_html) end test "operations payload should omit empty operations" do @@ -77,9 +73,7 @@ class CableReady::OperationBuilderTurboStreamTest < ActiveSupport::TestCase @operation_builder.add_operation_method("foo_bar") @operation_builder.foo_bar({beep_boop: "passed_option"}) - # the Turbo Helper current don't supoport custom additional attributes - # operations = [""] - operations = [""] + operations = [""] assert_equal(operations, @operation_builder.operations_payload) end @@ -185,9 +179,7 @@ class CableReady::OperationBuilderTurboStreamTest < ActiveSupport::TestCase @operation_builder.console_log(message: "Hello Console", level: "warn") - # the Turbo Helper current don't supoport custom additional attributes - # operations = [""] - operations = [""] + operations = [""] assert_equal(operations, @operation_builder.operations_payload) end From 3a657e5acc6af1494ee67a48a3fb84229886e625 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 20 Aug 2022 23:30:29 +0200 Subject: [PATCH 3/3] reference GitHub branch --- Gemfile | 2 ++ Gemfile.lock | 15 +++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index ac75bf0e..ec68bf26 100644 --- a/Gemfile +++ b/Gemfile @@ -4,3 +4,5 @@ source "https://rubygems.org" # Specify your gem's dependencies in cable_ready.gemspec gemspec + +gem 'turbo-rails', github: 'marcoroth/turbo-rails', branch: 'turbo-stream-additional-attributes' diff --git a/Gemfile.lock b/Gemfile.lock index 78b0c49e..b09636da 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,13 @@ +GIT + remote: https://github.com/marcoroth/turbo-rails.git + revision: d38861125d86fb6b4aa70d4db44afd81e609fa03 + branch: turbo-stream-additional-attributes + specs: + turbo-rails (1.1.1) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) + PATH remote: . specs: @@ -171,10 +181,6 @@ GEM standard thor (1.1.0) thread-local (1.1.0) - turbo-rails (1.1.1) - actionpack (>= 6.0.0) - activejob (>= 6.0.0) - railties (>= 6.0.0) tzinfo (2.0.4) concurrent-ruby (~> 1.0) unicode-display_width (2.1.0) @@ -198,6 +204,7 @@ DEPENDENCIES rake sqlite3 standardrb + turbo-rails! BUNDLED WITH 2.2.33