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
+
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/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