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 68884406..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: @@ -9,6 +19,7 @@ PATH activesupport (>= 5.2) railties (>= 5.2) thread-local (>= 1.1.0) + turbo-rails GEM remote: https://rubygems.org/ @@ -193,6 +204,7 @@ DEPENDENCIES rake sqlite3 standardrb + turbo-rails! BUNDLED WITH 2.2.33 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..b2db3a08 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,43 @@ 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] + 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, **attributes) + 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..ad2d7237 --- /dev/null +++ b/test/lib/cable_ready/operation_builder_turbo_stream_test.rb @@ -0,0 +1,186 @@ +# 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"}) + + 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"}) + + 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") + + operations = [""] + + assert_equal(operations, @operation_builder.operations_payload) + end +end