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 = ["I rock"]
+
+ 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 = [
+ "",
+ "I rock"
+ ]
+
+ 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 = ["winning"]
+
+ 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 = [
+ "",
+ "I rock",
+ "I rock too",
+ ]
+
+ 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 = ["I rock"]
+
+ 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 = ["I rock"]
+
+ 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 = ["I rock"]
+
+ 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 = ["You go, girl"]
+
+ 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