From 7b1d72e5d41be08e21b05a48397b18005e9df6b5 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 02:38:16 +0100 Subject: [PATCH 01/15] CableReady Element Co-authored-by: Marco Roth --- lib/stimulus_reflex/element.rb | 25 +++++++++++++++++++++++-- lib/stimulus_reflex/reflex.rb | 8 ++++++++ lib/stimulus_reflex/reflex_data.rb | 4 ---- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/lib/stimulus_reflex/element.rb b/lib/stimulus_reflex/element.rb index 5b9bd3f5..a80fe03f 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) + xpath = selector ? selector.starts_with?("/") : false + args = arguments.first.to_h.reverse_merge(selector: selector, xpath: xpath) + + cable_ready.send(method_name.to_sym, args) + + cable_ready + 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/reflex.rb b/lib/stimulus_reflex/reflex.rb index 10fc0626..47f43aea 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -109,6 +109,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 diff --git a/lib/stimulus_reflex/reflex_data.rb b/lib/stimulus_reflex/reflex_data.rb index 3cf75db5..24438a79 100644 --- a/lib/stimulus_reflex/reflex_data.rb +++ b/lib/stimulus_reflex/reflex_data.rb @@ -35,10 +35,6 @@ def url data[:url].to_s end - def element - StimulusReflex::Element.new(data) - end - def permanent_attribute_name data[:permanent_attribute_name] end From efaa6a46f3fc262a1c1b2e8a6bfecc49058359c5 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 02:39:46 +0100 Subject: [PATCH 02/15] Reflex Targets Co-authored-by: Marco Roth --- javascript/attributes.js | 25 ++++++++++++++++++++++++- javascript/schema.js | 3 ++- javascript/stimulus_reflex.js | 3 ++- lib/stimulus_reflex/reflex.rb | 19 ++++++++++++++++++- lib/stimulus_reflex/reflex_data.rb | 4 ++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/javascript/attributes.js b/javascript/attributes.js index 05f1c369..21aa9cd6 100644 --- a/javascript/attributes.js +++ b/javascript/attributes.js @@ -191,10 +191,33 @@ 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 = () => { + let targets = {} + + const targetElements = document.querySelectorAll(`[${Schema.reflexTarget}]`) + + targetElements.forEach(target => { + const targetName = target.dataset.reflexTarget + + targets[targetName] = { + selector: elementToXPath(target), + name: targetName, + dataset: extractElementDataset(target), + attrs: extractElementAttributes(target) + } + }) + + return targets +} + export { attributeValue, attributeValues, extractElementAttributes, extractElementDataset, - extractDataAttributes + extractDataAttributes, + extractTargets } diff --git a/javascript/schema.js b/javascript/schema.js index dd754529..737df5ee 100644 --- a/javascript/schema.js +++ b/javascript/schema.js @@ -8,7 +8,8 @@ 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' } let schema = {} diff --git a/javascript/stimulus_reflex.js b/javascript/stimulus_reflex.js index 3b50be9b..494ca1d4 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(), params, formData } diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb index 47f43aea..db076e44 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -19,7 +19,7 @@ class StimulusReflex::Reflex 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, to: :reflex_data # END TODO: remove alias_method :action_name, :method_name # for compatibility with controller libraries like Pundit that expect an action name @@ -32,6 +32,7 @@ def initialize(channel, reflex_data:) @payload = {} @headers = {} + define_targets check_version! end @@ -161,4 +162,20 @@ 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, details| + target_name = "#{name.to_s.underscore}_target".to_sym + + define_singleton_method(target_name) do + StimulusReflex::Element.new( + details, + selector: details["selector"], + cable_ready: cable_ready + ) + end + end + end end diff --git a/lib/stimulus_reflex/reflex_data.rb b/lib/stimulus_reflex/reflex_data.rb index 24438a79..db8779af 100644 --- a/lib/stimulus_reflex/reflex_data.rb +++ b/lib/stimulus_reflex/reflex_data.rb @@ -23,6 +23,10 @@ def target data[:target].to_s end + def targets + data[:targets] || {} + end + def method_name target.split("#").second end From b7808d29c510428b7783b343d17558b2139c6199 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 03:15:23 +0100 Subject: [PATCH 03/15] Fix method-chaining on reflex elements --- lib/stimulus_reflex/element.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/stimulus_reflex/element.rb b/lib/stimulus_reflex/element.rb index a80fe03f..6876f687 100644 --- a/lib/stimulus_reflex/element.rb +++ b/lib/stimulus_reflex/element.rb @@ -41,7 +41,7 @@ def method_missing(method_name, *arguments, &block) cable_ready.send(method_name.to_sym, args) - cable_ready + self else super end From a8b624321df0db98acc3a23898390e36511b8399 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 02:34:05 +0100 Subject: [PATCH 04/15] Include ReflexElement tests Co-authored-by: Marco Roth --- test/reflex_element_test.rb | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/reflex_element_test.rb diff --git a/test/reflex_element_test.rb b/test/reflex_element_test.rb new file mode 100644 index 00000000..6d74a145 --- /dev/null +++ b/test/reflex_element_test.rb @@ -0,0 +1,61 @@ +# 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 + + @element = StimulusReflex::Element.new({}, selector: "#element-selector") + + @reflex = StimulusReflex::Reflex.new(subscribe, url: "https://test.stimulusreflex.com", client_attributes: {id: "666", version: StimulusReflex::VERSION}) + @cable_ready = StimulusReflex::CableReadyChannels.new(@reflex) + @element.cable_ready = @cable_ready + 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" => "#element-selector", "xpath" => false, "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" => "#element-selector", "xpath" => false, "html" => "

Some HTML

", "reflexId" => "666", "operation" => "innerHtml"}, + {"name" => "abc", "detail" => {"some" => "key"}, "reflexId" => "666", "selector" => "#element-selector", "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 From 19f9a02275d9a87fa3b2747c1a92ac68a5b7600d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 15:27:30 +0100 Subject: [PATCH 05/15] Improve test setup and use xpath selectors --- test/reflex_element_test.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/reflex_element_test.rb b/test/reflex_element_test.rb index 6d74a145..53c6542b 100644 --- a/test/reflex_element_test.rb +++ b/test/reflex_element_test.rb @@ -12,11 +12,9 @@ def connection.env @env ||= {} end - @element = StimulusReflex::Element.new({}, selector: "#element-selector") - - @reflex = StimulusReflex::Reflex.new(subscribe, url: "https://test.stimulusreflex.com", client_attributes: {id: "666", version: StimulusReflex::VERSION}) - @cable_ready = StimulusReflex::CableReadyChannels.new(@reflex) - @element.cable_ready = @cable_ready + 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 = []) @@ -29,7 +27,7 @@ def build_payload(operations = []) test "broadcasts updates using element.broadcast" do expected = build_payload( - {"selector" => "#element-selector", "xpath" => false, "html" => "

Some HTML

", "reflexId" => "666", "operation" => "innerHtml"} + {"selector" => "/html/body/button[1]", "xpath" => true, "html" => "

Some HTML

", "reflexId" => "666", "operation" => "innerHtml"} ) assert_broadcast_on(@reflex.stream_name, expected) do @@ -50,8 +48,8 @@ def build_payload(operations = []) test "broadcasts using element.broadcast chained" do expected = build_payload [ - {"selector" => "#element-selector", "xpath" => false, "html" => "

Some HTML

", "reflexId" => "666", "operation" => "innerHtml"}, - {"name" => "abc", "detail" => {"some" => "key"}, "reflexId" => "666", "selector" => "#element-selector", "operation" => "dispatchEvent"} + {"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 From dde25feb0a994d7e8d9d54b14118378ecdc613ff Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 27 May 2023 15:31:48 +0100 Subject: [PATCH 06/15] Fix overriding element's selector via cable_ready selector: argument Fixes failing "selector can be overwritten" test in ReflexElementTest --- lib/stimulus_reflex/element.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/stimulus_reflex/element.rb b/lib/stimulus_reflex/element.rb index 6876f687..ef6862a6 100644 --- a/lib/stimulus_reflex/element.rb +++ b/lib/stimulus_reflex/element.rb @@ -36,10 +36,10 @@ def to_dom_id def method_missing(method_name, *arguments, &block) if cable_ready.respond_to?(method_name) - xpath = selector ? selector.starts_with?("/") : false - args = arguments.first.to_h.reverse_merge(selector: selector, xpath: xpath) + args = arguments.first.to_h + xpath = (args[:selector] || selector).starts_with?("/") - cable_ready.send(method_name.to_sym, args) + cable_ready.send(method_name.to_sym, args.reverse_merge(selector: selector, xpath: xpath)) self else From 27fe3fc3cfb0097c05bb8c097acd777f7595fb13 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Mon, 29 May 2023 15:52:03 +0100 Subject: [PATCH 07/15] Allow collections of multiple targets with the same name --- javascript/attributes.js | 8 +++--- lib/stimulus_reflex/reflex.rb | 21 ++++++++++++--- lib/stimulus_reflex/targets_collection.rb | 31 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 lib/stimulus_reflex/targets_collection.rb diff --git a/javascript/attributes.js b/javascript/attributes.js index 21aa9cd6..a392a2ef 100644 --- a/javascript/attributes.js +++ b/javascript/attributes.js @@ -202,12 +202,14 @@ const extractTargets = () => { targetElements.forEach(target => { const targetName = target.dataset.reflexTarget - targets[targetName] = { - selector: elementToXPath(target), + if (!targets.hasOwnProperty(targetName)) { targets[targetName] = [] } + + targets[targetName].push({ + selector: `[${Schema.reflexTarget}='${targetName}']`, name: targetName, dataset: extractElementDataset(target), attrs: extractElementAttributes(target) - } + }) }) return targets diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb index db076e44..a3523bea 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -2,6 +2,7 @@ require "stimulus_reflex/cable_readiness" require "stimulus_reflex/version_checker" +require "stimulus_reflex/targets_collection" class StimulusReflex::Reflex prepend StimulusReflex::CableReadiness @@ -166,16 +167,30 @@ def render_collection(resource, content = nil) private def define_targets - targets.each do |name, details| + targets.each do |name, elements| target_name = "#{name.to_s.underscore}_target".to_sym define_singleton_method(target_name) do StimulusReflex::Element.new( - details, - selector: details["selector"], + elements[0], + selector: elements[0]["selector"], cable_ready: cable_ready ) end + + if elements.many? + define_singleton_method("#{target_name}s") do + collection = elements.map do |element| + StimulusReflex::Element.new( + element, + selector: element["selector"], + cable_ready: cable_ready + ) + end + + StimulusReflex::TargetsCollection.new(collection, cable_ready: cable_ready) + end + end end end end diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb new file mode 100644 index 00000000..7e4962e3 --- /dev/null +++ b/lib/stimulus_reflex/targets_collection.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class StimulusReflex::TargetsCollection + attr_reader :target_elements, :cable_ready + + delegate :broadcast, to: :cable_ready + + def initialize(elements = [], cable_ready: nil) + @target_elements = elements + @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 + selector = target_elements.first.selector + + cable_ready.send(method_name.to_sym, args.merge(selector: selector, select_all: true)) + + self + else + target_elements.send(method_name) + end + end + + def respond_to_missing?(method_name, include_private = false) + cable_ready.respond_to?(method_name) || target_elements.respond_to?(method_name) + end +end From a471b34ee2c6984184590904fca4dc8e618787f5 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Tue, 30 May 2023 15:35:27 +0100 Subject: [PATCH 08/15] Use target name for select_all operations on collection --- javascript/attributes.js | 2 +- lib/stimulus_reflex/targets_collection.rb | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/javascript/attributes.js b/javascript/attributes.js index a392a2ef..d08f336e 100644 --- a/javascript/attributes.js +++ b/javascript/attributes.js @@ -205,7 +205,7 @@ const extractTargets = () => { if (!targets.hasOwnProperty(targetName)) { targets[targetName] = [] } targets[targetName].push({ - selector: `[${Schema.reflexTarget}='${targetName}']`, + selector: elementToXPath(target), name: targetName, dataset: extractElementDataset(target), attrs: extractElementAttributes(target) diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb index 7e4962e3..a1dd04ea 100644 --- a/lib/stimulus_reflex/targets_collection.rb +++ b/lib/stimulus_reflex/targets_collection.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true class StimulusReflex::TargetsCollection - attr_reader :target_elements, :cable_ready + attr_reader :target_name, :target_elements, :cable_ready delegate :broadcast, to: :cable_ready def initialize(elements = [], cable_ready: nil) @target_elements = elements + @target_name = elements.first.dataset.reflex_target @cable_ready = cable_ready end @@ -15,7 +16,7 @@ def initialize(elements = [], cable_ready: nil) def method_missing(method_name, *arguments, &block) if cable_ready.respond_to?(method_name) args = arguments.first.to_h - selector = target_elements.first.selector + selector = "[data-reflex-target='#{target_name}']" cable_ready.send(method_name.to_sym, args.merge(selector: selector, select_all: true)) From 7a94c375fd260f280c459e93de98655bf707003a Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sat, 24 Feb 2024 19:53:43 +0000 Subject: [PATCH 09/15] Ensure cable_ready is assigned to reflex before initialisation is complete --- lib/stimulus_reflex/reflex.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb index a3523bea..dbed49d3 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -5,7 +5,6 @@ require "stimulus_reflex/targets_collection" class StimulusReflex::Reflex - prepend StimulusReflex::CableReadiness include StimulusReflex::VersionChecker include StimulusReflex::Callbacks include ActiveSupport::Rescuable @@ -13,7 +12,7 @@ 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 @@ -30,6 +29,7 @@ def initialize(channel, reflex_data:) @channel = channel @reflex_data = reflex_data @broadcaster = StimulusReflex::PageBroadcaster.new(self) + @cable_ready = StimulusReflex::CableReadyChannels.new(self) @payload = {} @headers = {} From fb812f29170493e603362575cc5b50bf5129e7f0 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Tue, 30 May 2023 15:36:45 +0100 Subject: [PATCH 10/15] Delegate Array methods to target_elements more explicitly --- lib/stimulus_reflex/targets_collection.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb index a1dd04ea..7d4b5209 100644 --- a/lib/stimulus_reflex/targets_collection.rb +++ b/lib/stimulus_reflex/targets_collection.rb @@ -3,6 +3,7 @@ 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 = [], cable_ready: nil) @@ -21,12 +22,10 @@ def method_missing(method_name, *arguments, &block) cable_ready.send(method_name.to_sym, args.merge(selector: selector, select_all: true)) self - else - target_elements.send(method_name) end end def respond_to_missing?(method_name, include_private = false) - cable_ready.respond_to?(method_name) || target_elements.respond_to?(method_name) + cable_ready.respond_to?(method_name) end end From c5471ed1647b7fb86f78c812100d494607445c2d Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Tue, 30 May 2023 18:27:55 +0100 Subject: [PATCH 11/15] Limit included targets via data attribute --- javascript/attributes.js | 14 +++++++----- javascript/schema.js | 3 ++- javascript/stimulus_reflex.js | 2 +- lib/stimulus_reflex/reflex.rb | 28 ++++++++++------------- lib/stimulus_reflex/reflex_data.rb | 4 ++++ lib/stimulus_reflex/targets_collection.rb | 7 ++++-- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/javascript/attributes.js b/javascript/attributes.js index d08f336e..a1e3f811 100644 --- a/javascript/attributes.js +++ b/javascript/attributes.js @@ -194,17 +194,19 @@ const extractDataAttributes = element => { // Extracts all reflex targets from the current page. // Reflex targets are later available in the triggered reflex class // -const extractTargets = () => { - let targets = {} +const extractTargets = (includeTargets, controllerElement) => { + if (includeTargets === undefined) { return {} } - const targetElements = document.querySelectorAll(`[${Schema.reflexTarget}]`) + const targetsData = {} + const targetScope = (includeTargets === "controller") ? controllerElement : document + const targetElements = targetScope.querySelectorAll(`[${Schema.reflexTarget}]`) targetElements.forEach(target => { const targetName = target.dataset.reflexTarget - if (!targets.hasOwnProperty(targetName)) { targets[targetName] = [] } + if (!targetsData.hasOwnProperty(targetName)) { targetsData[targetName] = [] } - targets[targetName].push({ + targetsData[targetName].push({ selector: elementToXPath(target), name: targetName, dataset: extractElementDataset(target), @@ -212,7 +214,7 @@ const extractTargets = () => { }) }) - return targets + return targetsData } export { diff --git a/javascript/schema.js b/javascript/schema.js index 737df5ee..81eebc42 100644 --- a/javascript/schema.js +++ b/javascript/schema.js @@ -9,7 +9,8 @@ const defaultSchema = { reflexFormSelectorAttribute: 'data-reflex-form-selector', reflexIncludeInnerHtmlAttribute: 'data-reflex-include-inner-html', reflexIncludeTextContentAttribute: 'data-reflex-include-text-content', - reflexTargetAttribute: 'data-reflex-target' + reflexTargetAttribute: 'data-reflex-target', + includeTargetsAttribute: 'data-include-targets' } let schema = {} diff --git a/javascript/stimulus_reflex.js b/javascript/stimulus_reflex.js index 494ca1d4..a81fafbd 100644 --- a/javascript/stimulus_reflex.js +++ b/javascript/stimulus_reflex.js @@ -169,7 +169,7 @@ const register = (controller, options = {}) => { reflex.data = { ...reflexData.valueOf(), - targets: extractTargets(), + targets: extractTargets(reflexData.dataset.dataset[Schema.includeTargets], controllerElement), params, formData } diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb index dbed49d3..29b10304 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -19,7 +19,7 @@ class StimulusReflex::Reflex 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, :targets, 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 @@ -170,26 +170,22 @@ def define_targets targets.each do |name, elements| target_name = "#{name.to_s.underscore}_target".to_sym - define_singleton_method(target_name) do + collection = elements.map do |element| StimulusReflex::Element.new( - elements[0], - selector: elements[0]["selector"], + element, + selector: element["selector"], cable_ready: cable_ready ) end - if elements.many? - define_singleton_method("#{target_name}s") do - collection = elements.map do |element| - StimulusReflex::Element.new( - element, - selector: element["selector"], - cable_ready: cable_ready - ) - end - - StimulusReflex::TargetsCollection.new(collection, 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_scope: target_scope, reflex_controller: reflex_controller, cable_ready: cable_ready + ) end end end diff --git a/lib/stimulus_reflex/reflex_data.rb b/lib/stimulus_reflex/reflex_data.rb index db8779af..1cd79da8 100644 --- a/lib/stimulus_reflex/reflex_data.rb +++ b/lib/stimulus_reflex/reflex_data.rb @@ -27,6 +27,10 @@ def targets data[:targets] || {} end + def target_scope + data.dig(:dataset, :dataset, :data_include_targets) + end + def method_name target.split("#").second end diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb index 7d4b5209..6e00c83d 100644 --- a/lib/stimulus_reflex/targets_collection.rb +++ b/lib/stimulus_reflex/targets_collection.rb @@ -6,9 +6,11 @@ class StimulusReflex::TargetsCollection delegate *Array.instance_methods.excluding(:__send__, :object_id), to: :target_elements delegate :broadcast, to: :cable_ready - def initialize(elements = [], cable_ready: nil) + def initialize(elements = [], target_scope: nil, reflex_controller: nil, cable_ready: nil) @target_elements = elements @target_name = elements.first.dataset.reflex_target + @target_scope = target_scope + @reflex_controller = reflex_controller @cable_ready = cable_ready end @@ -17,7 +19,8 @@ def initialize(elements = [], cable_ready: nil) def method_missing(method_name, *arguments, &block) if cable_ready.respond_to?(method_name) args = arguments.first.to_h - selector = "[data-reflex-target='#{target_name}']" + 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)) From 415f4ae1904adfa56ddc10e58a3fa1b25bbf39a5 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Wed, 31 May 2023 12:06:01 +0100 Subject: [PATCH 12/15] Gracefully handle chained operations on potentially missing targets --- lib/stimulus_reflex/missing_target.rb | 31 +++++++++++++++++++++++++++ lib/stimulus_reflex/reflex.rb | 13 +++++++++++ 2 files changed, 44 insertions(+) create mode 100644 lib/stimulus_reflex/missing_target.rb 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 29b10304..f4c21b0e 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -3,6 +3,7 @@ require "stimulus_reflex/cable_readiness" require "stimulus_reflex/version_checker" require "stimulus_reflex/targets_collection" +require "stimulus_reflex/missing_target" class StimulusReflex::Reflex include StimulusReflex::VersionChecker @@ -189,4 +190,16 @@ def define_targets 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 From 14a1a0c74781563c09f16139370ba1f53e1a384f Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Sun, 25 Feb 2024 01:16:51 +0000 Subject: [PATCH 13/15] Fix edge cases where methods can be delegated to a nil @logger object in StimulusReflex::Logger --- lib/stimulus_reflex/utils/logger.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 8cfba5df4b1013f7f21f7c55ce65ec2bfb8a9110 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Wed, 31 May 2023 15:52:32 +0100 Subject: [PATCH 14/15] Simplify TargetsCollection target name assignment --- lib/stimulus_reflex/reflex.rb | 6 +++++- lib/stimulus_reflex/targets_collection.rb | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb index f4c21b0e..7954f262 100644 --- a/lib/stimulus_reflex/reflex.rb +++ b/lib/stimulus_reflex/reflex.rb @@ -185,7 +185,11 @@ def define_targets define_singleton_method("#{target_name}s") do StimulusReflex::TargetsCollection.new( - collection, target_scope: target_scope, reflex_controller: reflex_controller, cable_ready: cable_ready + collection, + target_name: name, + target_scope: target_scope, + reflex_controller: reflex_controller, + cable_ready: cable_ready ) end end diff --git a/lib/stimulus_reflex/targets_collection.rb b/lib/stimulus_reflex/targets_collection.rb index 6e00c83d..0f3ba248 100644 --- a/lib/stimulus_reflex/targets_collection.rb +++ b/lib/stimulus_reflex/targets_collection.rb @@ -6,9 +6,9 @@ class StimulusReflex::TargetsCollection delegate *Array.instance_methods.excluding(:__send__, :object_id), to: :target_elements delegate :broadcast, to: :cable_ready - def initialize(elements = [], target_scope: nil, reflex_controller: nil, cable_ready: nil) + def initialize(elements = [], target_name: "", target_scope: nil, reflex_controller: nil, cable_ready: nil) @target_elements = elements - @target_name = elements.first.dataset.reflex_target + @target_name = target_name @target_scope = target_scope @reflex_controller = reflex_controller @cable_ready = cable_ready From 4ab5b6051fd7f560ac4617861e120b2552ad74c0 Mon Sep 17 00:00:00 2001 From: Matt-Yorkley <9029026+Matt-Yorkley@users.noreply.github.com> Date: Wed, 31 May 2023 15:57:48 +0100 Subject: [PATCH 15/15] Add tests for targets and target collections --- .../test/attributes.extractTargets.test.js | 58 ++++++ test/reflex_targets_test.rb | 168 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 javascript/test/attributes.extractTargets.test.js create mode 100644 test/reflex_targets_test.rb 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
+
Comment 1
+
Comment 2
+ ` + ) + 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` +
+
In
+
+
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/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