Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion javascript/attributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 3 additions & 1 deletion javascript/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
3 changes: 2 additions & 1 deletion javascript/stimulus_reflex.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -169,6 +169,7 @@ const register = (controller, options = {}) => {

reflex.data = {
...reflexData.valueOf(),
targets: extractTargets(reflexData.dataset.dataset[Schema.includeTargets], controllerElement),
params,
formData
}
Expand Down
58 changes: 58 additions & 0 deletions javascript/test/attributes.extractTargets.test.js
Original file line number Diff line number Diff line change
@@ -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`<div data-reflex-target="post">Post</div>`
)
const targets = extractTargets(undefined, null)
assert.deepStrictEqual(targets, {})
})

it('should extract multiple targets from page', async () => {
const dom = await fixture(
html`
<div data-reflex-target="post">Post</div>
<div data-reflex-target="comment" class="comment-1">Comment 1</div>
<div data-reflex-target="comment" class="comment-2">Comment 2</div>
`
)
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`
<div data-controller="test">
<div data-reflex-target="included">In</div>
</div>
<div data-reflex-target="not_included">Out</div>
`
)

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)
})
})
25 changes: 23 additions & 2 deletions lib/stimulus_reflex/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
31 changes: 31 additions & 0 deletions lib/stimulus_reflex/missing_target.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 56 additions & 3 deletions lib/stimulus_reflex/reflex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,25 @@

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
include ActionView::Helpers::TagHelper
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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 8 additions & 4 deletions lib/stimulus_reflex/reflex_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions lib/stimulus_reflex/targets_collection.rb
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/stimulus_reflex/utils/logger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading