diff --git a/javascript/attributes.js b/javascript/attributes.js
index 05f1c369..a1e3f811 100644
--- a/javascript/attributes.js
+++ b/javascript/attributes.js
@@ -191,10 +191,37 @@ const extractDataAttributes = element => {
return attrs
}
+// Extracts all reflex targets from the current page.
+// Reflex targets are later available in the triggered reflex class
+//
+const extractTargets = (includeTargets, controllerElement) => {
+ if (includeTargets === undefined) { return {} }
+
+ const targetsData = {}
+ const targetScope = (includeTargets === "controller") ? controllerElement : document
+ const targetElements = targetScope.querySelectorAll(`[${Schema.reflexTarget}]`)
+
+ targetElements.forEach(target => {
+ const targetName = target.dataset.reflexTarget
+
+ if (!targetsData.hasOwnProperty(targetName)) { targetsData[targetName] = [] }
+
+ targetsData[targetName].push({
+ selector: elementToXPath(target),
+ name: targetName,
+ dataset: extractElementDataset(target),
+ attrs: extractElementAttributes(target)
+ })
+ })
+
+ return targetsData
+}
+
export {
attributeValue,
attributeValues,
extractElementAttributes,
extractElementDataset,
- extractDataAttributes
+ extractDataAttributes,
+ extractTargets
}
diff --git a/javascript/schema.js b/javascript/schema.js
index dd754529..81eebc42 100644
--- a/javascript/schema.js
+++ b/javascript/schema.js
@@ -8,7 +8,9 @@ const defaultSchema = {
reflexSerializeFormAttribute: 'data-reflex-serialize-form',
reflexFormSelectorAttribute: 'data-reflex-form-selector',
reflexIncludeInnerHtmlAttribute: 'data-reflex-include-inner-html',
- reflexIncludeTextContentAttribute: 'data-reflex-include-text-content'
+ reflexIncludeTextContentAttribute: 'data-reflex-include-text-content',
+ reflexTargetAttribute: 'data-reflex-target',
+ includeTargetsAttribute: 'data-include-targets'
}
let schema = {}
diff --git a/javascript/stimulus_reflex.js b/javascript/stimulus_reflex.js
index 3b50be9b..a81fafbd 100644
--- a/javascript/stimulus_reflex.js
+++ b/javascript/stimulus_reflex.js
@@ -13,7 +13,7 @@ import ReflexData from './reflex_data'
import Schema from './schema'
import Transport from './transport'
-import { attributeValues } from './attributes'
+import { attributeValues, extractTargets } from './attributes'
import { beforeDOMUpdate, afterDOMUpdate, routeReflexEvent } from './callbacks'
import { dispatchLifecycleEvent } from './lifecycle'
import { reflexes } from './reflexes'
@@ -169,6 +169,7 @@ const register = (controller, options = {}) => {
reflex.data = {
...reflexData.valueOf(),
+ targets: extractTargets(reflexData.dataset.dataset[Schema.includeTargets], controllerElement),
params,
formData
}
diff --git a/javascript/test/attributes.extractTargets.test.js b/javascript/test/attributes.extractTargets.test.js
new file mode 100644
index 00000000..6a7bd715
--- /dev/null
+++ b/javascript/test/attributes.extractTargets.test.js
@@ -0,0 +1,58 @@
+import { html, fixture, assert } from '@open-wc/testing'
+
+import { extractTargets } from '../attributes'
+import Schema, { defaultSchema } from '../schema'
+
+Schema.set(defaultSchema)
+
+describe('extractTargets', () => {
+ it('should extract no targets by default', async () => {
+ const dom = await fixture(
+ html`
Post
`
+ )
+ const targets = extractTargets(undefined, null)
+ assert.deepStrictEqual(targets, {})
+ })
+
+ it('should extract multiple targets from page', async () => {
+ const dom = await fixture(
+ html`
+ Post
+
+
+ `
+ )
+ const targets = extractTargets("page", null)
+
+ assert.equal(targets["post"][0]["name"], "post")
+ assert.equal(targets["post"][0]["selector"], "/html/body/div[1]/div[1]")
+ assert.equal(targets["post"][0]["attrs"]["data-reflex-target"], "post")
+
+ assert.equal(targets["comment"][0]["name"], "comment")
+ assert.equal(targets["comment"][0]["selector"], "/html/body/div[1]/div[2]")
+ assert.equal(targets["comment"][0]["attrs"]["data-reflex-target"], "comment")
+ assert.equal(targets["comment"][0]["attrs"]["class"], "comment-1")
+
+ assert.equal(targets["comment"][1]["name"], "comment")
+ assert.equal(targets["comment"][1]["selector"], "/html/body/div[1]/div[3]")
+ assert.equal(targets["comment"][1]["attrs"]["data-reflex-target"], "comment")
+ assert.equal(targets["comment"][1]["attrs"]["class"], "comment-2")
+ })
+
+ it('should limit targets to parent controller if specified', async () => {
+ const controller = await fixture( // Note: fixture() returns the first element in the DOM
+ html`
+
+ Out
+ `
+ )
+
+ const targets = extractTargets("controller", controller)
+
+ assert.equal(targets["included"][0]["name"], "included")
+ assert.equal(targets["included"][0]["selector"], "/html/body/div[1]/div[1]/div[1]")
+ assert.equal(targets["not_included"], undefined)
+ })
+})
diff --git a/lib/stimulus_reflex/element.rb b/lib/stimulus_reflex/element.rb
index 5b9bd3f5..ef6862a6 100644
--- a/lib/stimulus_reflex/element.rb
+++ b/lib/stimulus_reflex/element.rb
@@ -6,15 +6,19 @@
class StimulusReflex::Element < OpenStruct
include StimulusReflex::AttributeBuilder
- attr_reader :attrs, :dataset
+ attr_reader :attrs, :dataset, :selector
+ attr_accessor :cable_ready
alias_method :data_attributes, :dataset
delegate :signed, :unsigned, :numeric, :boolean, :data_attrs, to: :dataset
+ delegate :broadcast, to: :cable_ready
- def initialize(data = {})
+ def initialize(data = {}, selector: nil, cable_ready: nil)
+ @selector = selector
@attrs = HashWithIndifferentAccess.new(data["attrs"] || {})
@dataset = StimulusReflex::Dataset.new(data)
+ @cable_ready = cable_ready
all_attributes = @attrs.merge(@dataset.attrs)
super build_underscored(all_attributes)
@@ -29,4 +33,21 @@ def to_dom_id
"##{id}"
end
+
+ def method_missing(method_name, *arguments, &block)
+ if cable_ready.respond_to?(method_name)
+ args = arguments.first.to_h
+ xpath = (args[:selector] || selector).starts_with?("/")
+
+ cable_ready.send(method_name.to_sym, args.reverse_merge(selector: selector, xpath: xpath))
+
+ self
+ else
+ super
+ end
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ cable_ready.respond_to?(method_name) || super
+ end
end
diff --git a/lib/stimulus_reflex/missing_target.rb b/lib/stimulus_reflex/missing_target.rb
new file mode 100644
index 00000000..f3aed7a9
--- /dev/null
+++ b/lib/stimulus_reflex/missing_target.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+# A null object that responds to methods like any? and many? with: false, and methods like
+# count and length with: 0, and recursively cancels any chained operations invoked on itself.
+
+class StimulusReflex::MissingTarget
+ attr_reader :target_elements
+
+ delegate *Array.instance_methods.excluding(:__send__, :object_id), to: :target_elements
+
+ def initialize
+ @target_elements = []
+ end
+
+ # Silently cancels any/all methods that might have been chained onto a missing target and
+ # avoids the associated exceptions that would have arisen from invoking the chained method(s)
+ # on NilClass (for a method chain of N length), allowing code execution to continue. Eg;
+ #
+ # def targets_example
+ # nonexistent_target.inner_html(...).add_css_class(...) # All operations ignored
+ # existing_target.add_css_class(...) # Still gets executed
+ # end
+ #
+ def method_missing(_method_name, *_arguments, &_block)
+ self
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ StimulusReflex::CableReadyChannels.public_instance_methods.include? method_name
+ end
+end
diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb
index 10fc0626..7954f262 100644
--- a/lib/stimulus_reflex/reflex.rb
+++ b/lib/stimulus_reflex/reflex.rb
@@ -2,9 +2,10 @@
require "stimulus_reflex/cable_readiness"
require "stimulus_reflex/version_checker"
+require "stimulus_reflex/targets_collection"
+require "stimulus_reflex/missing_target"
class StimulusReflex::Reflex
- prepend StimulusReflex::CableReadiness
include StimulusReflex::VersionChecker
include StimulusReflex::Callbacks
include ActiveSupport::Rescuable
@@ -12,14 +13,14 @@ class StimulusReflex::Reflex
include CableReady::Identifiable
attr_accessor :payload, :headers
- attr_reader :channel, :reflex_data, :broadcaster
+ attr_reader :channel, :reflex_data, :cable_ready, :broadcaster
delegate :connection, :stream_name, to: :channel
delegate :controller_class, :flash, :session, to: :request
delegate :broadcast, :broadcast_halt, :broadcast_forbid, :broadcast_error, to: :broadcaster
# TODO remove xpath_controller and xpath_element for v4
- delegate :url, :element, :selectors, :method_name, :id, :tab_id, :reflex_controller, :xpath_controller, :xpath_element, :permanent_attribute_name, :version, :npm_version, :suppress_logging, to: :reflex_data
+ delegate :url, :element, :selectors, :method_name, :id, :tab_id, :reflex_controller, :xpath_controller, :xpath_element, :permanent_attribute_name, :version, :npm_version, :suppress_logging, :targets, :target_scope, to: :reflex_data
# END TODO: remove
alias_method :action_name, :method_name # for compatibility with controller libraries like Pundit that expect an action name
@@ -29,9 +30,11 @@ def initialize(channel, reflex_data:)
@channel = channel
@reflex_data = reflex_data
@broadcaster = StimulusReflex::PageBroadcaster.new(self)
+ @cable_ready = StimulusReflex::CableReadyChannels.new(self)
@payload = {}
@headers = {}
+ define_targets
check_version!
end
@@ -109,6 +112,14 @@ def controller
@controller
end
+ def element
+ @element ||= StimulusReflex::Element.new(reflex_data.data, selector: xpath_element, cable_ready: cable_ready)
+ end
+
+ def controller_element
+ @controller_element ||= StimulusReflex::Element.new(selector: xpath_controller, cable_ready: cable_ready)
+ end
+
def controller?
!!defined? @controller
end
@@ -153,4 +164,46 @@ def render_collection(resource, content = nil)
content ||= render(resource)
tag.div(content.html_safe, id: dom_id(resource).from(1))
end
+
+ private
+
+ def define_targets
+ targets.each do |name, elements|
+ target_name = "#{name.to_s.underscore}_target".to_sym
+
+ collection = elements.map do |element|
+ StimulusReflex::Element.new(
+ element,
+ selector: element["selector"],
+ cable_ready: cable_ready
+ )
+ end
+
+ define_singleton_method(target_name) do
+ collection.first
+ end
+
+ define_singleton_method("#{target_name}s") do
+ StimulusReflex::TargetsCollection.new(
+ collection,
+ target_name: name,
+ target_scope: target_scope,
+ reflex_controller: reflex_controller,
+ cable_ready: cable_ready
+ )
+ end
+ end
+ end
+
+ def method_missing(method_name, *arguments, &block)
+ return super unless method_name.to_s.include? "_target"
+
+ target_missing(method_name)
+ StimulusReflex::MissingTarget.new
+ end
+
+ # Inheriting Reflex classes can override this method to customise missing target behaviour
+ def target_missing(target_name)
+ logger.info "#{self.class.name}: No #{target_name} present, skipping operations on #{target_name}"
+ end
end
diff --git a/lib/stimulus_reflex/reflex_data.rb b/lib/stimulus_reflex/reflex_data.rb
index 3cf75db5..1cd79da8 100644
--- a/lib/stimulus_reflex/reflex_data.rb
+++ b/lib/stimulus_reflex/reflex_data.rb
@@ -23,6 +23,14 @@ def target
data[:target].to_s
end
+ def targets
+ data[:targets] || {}
+ end
+
+ def target_scope
+ data.dig(:dataset, :dataset, :data_include_targets)
+ end
+
def method_name
target.split("#").second
end
@@ -35,10 +43,6 @@ def url
data[:url].to_s
end
- def element
- StimulusReflex::Element.new(data)
- end
-
def permanent_attribute_name
data[:permanent_attribute_name]
end
diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb
new file mode 100644
index 00000000..0f3ba248
--- /dev/null
+++ b/lib/stimulus_reflex/targets_collection.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class StimulusReflex::TargetsCollection
+ attr_reader :target_name, :target_elements, :cable_ready
+
+ delegate *Array.instance_methods.excluding(:__send__, :object_id), to: :target_elements
+ delegate :broadcast, to: :cable_ready
+
+ def initialize(elements = [], target_name: "", target_scope: nil, reflex_controller: nil, cable_ready: nil)
+ @target_elements = elements
+ @target_name = target_name
+ @target_scope = target_scope
+ @reflex_controller = reflex_controller
+ @cable_ready = cable_ready
+ end
+
+ private
+
+ def method_missing(method_name, *arguments, &block)
+ if cable_ready.respond_to?(method_name)
+ args = arguments.first.to_h
+ parent = @target_scope == "controller" ? "[data-controller='#{@reflex_controller}'] " : nil
+ selector = "#{parent}[data-reflex-target='#{target_name}']"
+
+ cable_ready.send(method_name.to_sym, args.merge(selector: selector, select_all: true))
+
+ self
+ end
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ cable_ready.respond_to?(method_name)
+ end
+end
diff --git a/lib/stimulus_reflex/utils/logger.rb b/lib/stimulus_reflex/utils/logger.rb
index dd1c7477..30f24b82 100644
--- a/lib/stimulus_reflex/utils/logger.rb
+++ b/lib/stimulus_reflex/utils/logger.rb
@@ -10,7 +10,7 @@ class Logger
def initialize(reflex)
@reflex = reflex
@current_operation = 1
- @logger = StimulusReflex.config.logger
+ @logger = StimulusReflex.config.logger || ::Logger.new(nil)
end
def log_all_operations
diff --git a/test/reflex_element_test.rb b/test/reflex_element_test.rb
new file mode 100644
index 00000000..53c6542b
--- /dev/null
+++ b/test/reflex_element_test.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "mocha/minitest"
+
+class StimulusReflex::ReflexElementTest < ActionCable::Channel::TestCase
+ tests StimulusReflex::Channel
+
+ setup do
+ stub_connection(session_id: SecureRandom.uuid)
+ def connection.env
+ @env ||= {}
+ end
+
+ reflex_data = StimulusReflex::ReflexData.new(xpath_element: "/html/body/button[1]", url: "https://test.stimulusreflex.com", id: "666", version: StimulusReflex::VERSION)
+ @reflex = StimulusReflex::Reflex.new(subscribe, reflex_data: reflex_data)
+ @element = @reflex.element
+ end
+
+ def build_payload(operations = [])
+ {
+ "cableReady" => true,
+ "operations" => Array.wrap(operations),
+ "version" => CableReady::VERSION
+ }
+ end
+
+ test "broadcasts updates using element.broadcast" do
+ expected = build_payload(
+ {"selector" => "/html/body/button[1]", "xpath" => true, "html" => "Some HTML
", "reflexId" => "666", "operation" => "innerHtml"}
+ )
+
+ assert_broadcast_on(@reflex.stream_name, expected) do
+ @element.inner_html(html: "Some HTML
")
+ @element.broadcast
+ end
+ end
+
+ test "selector can be overwritten" do
+ expected = build_payload(
+ {"selector" => "#overwritten", "xpath" => false, "html" => "Some HTML
", "reflexId" => "666", "operation" => "innerHtml"}
+ )
+
+ assert_broadcast_on(@reflex.stream_name, expected) do
+ @element.inner_html(html: "Some HTML
", selector: "#overwritten").broadcast
+ end
+ end
+
+ test "broadcasts using element.broadcast chained" do
+ expected = build_payload [
+ {"selector" => "/html/body/button[1]", "xpath" => true, "html" => "Some HTML
", "reflexId" => "666", "operation" => "innerHtml"},
+ {"name" => "abc", "detail" => {"some" => "key"}, "reflexId" => "666", "selector" => "/html/body/button[1]", "xpath" => true, "operation" => "dispatchEvent"}
+ ]
+
+ assert_broadcast_on(@reflex.stream_name, expected) do
+ @element.inner_html(html: "Some HTML
").dispatch_event(name: "abc", detail: {some: "key"}).broadcast
+ end
+ end
+end
diff --git a/test/reflex_targets_test.rb b/test/reflex_targets_test.rb
new file mode 100644
index 00000000..d8c5d09d
--- /dev/null
+++ b/test/reflex_targets_test.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+require "mocha/minitest"
+
+class StimulusReflex::ReflexTargetsTest < ActionCable::Channel::TestCase
+ tests StimulusReflex::Channel
+
+ attr_reader :reflex
+ delegate :post_targets, :button_target, :absent_target, :unicorn_targets, :cable_ready, to: :reflex
+
+ setup do
+ stub_connection(session_id: SecureRandom.uuid)
+ def connection.env
+ @env ||= {}
+ end
+
+ @element = StimulusReflex::Element.new({}, selector: "/html/body/button[1]")
+ @reflex = build_with_targets
+ end
+
+ def build_with_targets(targets_data: nil, target_scope: "page")
+ targets_data ||= {
+ "post" => [
+ { "name" => "post", "selector" => "/html/body/div[1]", "attrs" => { "class" => "" } },
+ { "name" => "post", "selector" => "/html/body/div[2]", "attrs" => { "class" => "special" } },
+ { "name" => "post", "selector" => "/html/body/div[3]", "attrs" => { "class" => "special" } }
+ ],
+ "button" => [
+ { "name" => "button", "selector" => "/html/body/button[1]", "dataset" => {} }
+ ]
+ }
+
+ reflex_data = StimulusReflex::ReflexData.new(
+ element: @element,
+ url: "https://test.stimulusreflex.com",
+ targets: targets_data,
+ id: "123",
+ version: StimulusReflex::VERSION,
+ reflex_controller: "stimulus-reflex",
+ target_scope: target_scope
+ )
+
+ StimulusReflex::Reflex.new(subscribe, reflex_data: reflex_data)
+ end
+
+ def build_payload(operations = [])
+ {
+ "cableReady" => true,
+ "operations" => Array.wrap(operations),
+ "version" => CableReady::VERSION
+ }
+ end
+
+ test "shares a cable_ready instance with targets and target collections" do
+ assert_equal reflex.cable_ready, button_target.cable_ready
+ assert_equal reflex.cable_ready, post_targets.cable_ready
+ assert_equal reflex.cable_ready, post_targets.first.cable_ready
+ end
+
+ test "builds chainable operations on a (singular) target" do
+ expected = build_payload(
+ [
+ {"selector" => "/html/body/button[1]", "xpath" => true, "name" => "updated", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "/html/body/button[1]", "xpath" => true, "text" => "Button", "reflexId" => "123", "operation" => "textContent"}
+ ]
+ )
+
+ assert_broadcast_on(reflex.stream_name, expected) do
+ button_target.add_css_class(name: "updated").text_content(text: "Button")
+
+ reflex.cable_ready.broadcast
+ end
+ end
+
+ test "builds chainable operations on (plural) multi-target collection using select_all" do
+ expected = build_payload(
+ [
+ {"selector" => "[data-reflex-target='post']", "selectAll" => true, "name" => "updated", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "[data-reflex-target='post']", "selectAll" => true, "text" => "Post", "reflexId" => "123", "operation" => "textContent"}
+ ]
+ )
+
+ assert_broadcast_on(reflex.stream_name, expected) do
+ post_targets.add_css_class(name: "updated").text_content(text: "Post")
+
+ reflex.cable_ready.broadcast
+ end
+ end
+
+ test "target collections respond to array-like interface" do
+ assert_equal post_targets.any?, true
+ assert_equal post_targets.many?, true
+ assert_equal post_targets.count, 3
+ assert_equal post_targets.first.selector, "/html/body/div[1]"
+ assert_equal post_targets.last.selector, "/html/body/div[3]"
+
+ special_targets = post_targets.select{ |target| target.attrs[:class].include?("special") }
+
+ assert_equal special_targets.count, 2
+ assert_equal special_targets.first.selector, "/html/body/div[2]"
+ assert_equal special_targets.last.selector, "/html/body/div[3]"
+ end
+
+ test "doesn't raise an exception / halt execution if operation(s) are called on a missing target" do
+ expected = build_payload(
+ [
+ {"selector" => "/html/body/button[1]", "xpath" => true, "name" => "success", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "/html/body/button[1]", "xpath" => true, "text" => "I'm still updated!", "reflexId" => "123", "operation" => "textContent"}
+ ]
+ )
+
+ assert_broadcast_on(reflex.stream_name, expected) do
+ absent_target.add_css_class(name: "nope").text_content(text: "I'm not even here!")
+ button_target.add_css_class(name: "success").text_content(text: "I'm still updated!")
+
+ reflex.cable_ready.broadcast
+ end
+ end
+
+ test "missing/undefined targets that *might* exist but are currently not in the DOM still respond to inspection" do
+ assert_equal absent_target.any?, false
+ assert_equal absent_target.present?, false
+ assert_equal unicorn_targets.count, 0
+ assert_equal unicorn_targets.first.present?, false
+ assert_equal unicorn_targets.any?, false
+ end
+
+ test "targets in a multi-target collection can also be operated on individually" do
+ expected = build_payload(
+ [
+ {"selector" => "/html/body/div[2]", "xpath" => true, "name" => "upgrade", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "/html/body/div[3]", "xpath" => true, "name" => "upgrade", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "/html/body/div[1]", "xpath" => true, "name" => "downgrade", "reflexId" => "123", "operation" => "addCssClass"}
+ ]
+ )
+
+ assert_broadcast_on(reflex.stream_name, expected) do
+ post_targets
+ .select{ |target| target.attrs[:class].include?("special") }
+ .each{ |target| target.add_css_class(name: "upgrade") }
+
+ post_targets
+ .find{ |target| target.attrs[:class].blank? }
+ .add_css_class(name: "downgrade")
+
+ reflex.cable_ready.broadcast
+ end
+ end
+
+ test "plays nicely with other operations interspersed" do
+ expected = build_payload(
+ [
+ {"selector" => "[data-reflex-target='post']", "selectAll" => true, "name" => "hey", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "#other", "name" => "thing", "reflexId" => "123", "operation" => "addCssClass"},
+ {"selector" => "[data-reflex-target='post']", "selectAll" => true, "text" => "I'm a Post", "reflexId" => "123", "operation" => "textContent"}
+ ]
+ )
+
+ assert_broadcast_on(reflex.stream_name, expected) do
+ post_targets.add_css_class(name: "hey")
+ cable_ready.add_css_class(selector: "#other", name: "thing")
+ post_targets.text_content(text: "I'm a Post")
+
+ reflex.cable_ready.broadcast
+ end
+ end
+end