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
11 changes: 7 additions & 4 deletions app/channels/stimulus_reflex/channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,34 @@ def receive(data)
selectors = (data["selectors"] || []).select(&:present?)
selectors = data["selectors"] = ["body"] if selectors.blank?
target = data["target"].to_s
targets = data["targets"] || {}
reflex_name, method_name = target.split("#")
reflex_name = reflex_name.camelize
reflex_name = reflex_name.end_with?("Reflex") ? reflex_name : "#{reflex_name}Reflex"
arguments = (data["args"] || []).map { |arg| object_with_indifferent_access arg }
element = StimulusReflex::Element.new(data)
permanent_attribute_name = data["permanentAttributeName"]
form_data = Rack::Utils.parse_nested_query(data["formData"])
params = form_data.deep_merge(data["params"] || {})

begin
begin
reflex_class = reflex_name.constantize.tap { |klass| raise ArgumentError.new("#{reflex_name} is not a StimulusReflex::Reflex") unless is_reflex?(klass) }
reflex = reflex_class.new(self,
reflex = reflex_class.new(
self,
url: url,
element: element,
data: data,
selectors: selectors,
method_name: method_name,
targets: targets,
params: params,
client_attributes: {
reflex_id: data["reflexId"],
xpath_controller: data["xpathController"],
xpath_element: data["xpathElement"],
reflex_controller: data["reflexController"],
permanent_attribute_name: permanent_attribute_name
})
}
)
delegate_call_to_reflex reflex, method_name, arguments
rescue => invoke_error
message = exception_message_with_backtrace(invoke_error)
Expand Down
89 changes: 76 additions & 13 deletions javascript/attributes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defaultSchema } from './schema'
import reflexes from './reflexes'
import { elementToXPath, XPathToArray } from './utils'
import Debug from './debug'
// import Deprecate from './deprecate'

const multipleInstances = element => {
if (['checkbox', 'radio'].includes(element.type)) {
Expand Down Expand Up @@ -68,22 +70,59 @@ export const extractElementAttributes = element => {
return attrs
}

// Extracts the dataset of an element and combines it with the data attributes from all parents if requested.
// Extracts the dataset of an element and combines it with the data attributes from all specified tokens
//
export const extractElementDataset = (element, datasetAttribute = null) => {
let attrs = extractDataAttributes(element) || {}
const dataset = datasetAttribute && element.attributes[datasetAttribute]
export const extractElementDataset = element => {
let elements = [element]
const xPath = elementToXPath(element)
const dataset = element.attributes[reflexes.app.schema.reflexDatasetAttribute]
const tokens = (dataset && dataset.value.split(' ')) || []

if (dataset && dataset.value === 'combined') {
let parent = element.parentElement

while (parent) {
attrs = { ...extractDataAttributes(parent), ...attrs }
parent = parent.parentElement
tokens.forEach(token => {
try {
switch (token) {
case 'combined':
// uncomment when SR#438 is merged
// if (Deprecate.enabled) console.warn("In the next version of StimulusReflex, the 'combined' option to data-reflex-dataset will become 'ancestors'.")
elements = [
...elements,
...XPathToArray(`${xPath}/ancestor::*`, true)
]
break
case 'ancestors':
elements = [
...elements,
...XPathToArray(`${xPath}/ancestor::*`, true)
]
break
case 'parent':
elements = [...elements, ...XPathToArray(`${xPath}/parent::*`)]
break
case 'siblings':
elements = [
...elements,
...XPathToArray(
`${xPath}/preceding-sibling::*|${xPath}/following-sibling::*`
)
]
break
case 'children':
elements = [...elements, ...XPathToArray(`${xPath}/child::*`)]
break
case 'descendants':
elements = [...elements, ...XPathToArray(`${xPath}/descendant::*`)]
break
default:
elements = [...elements, ...document.querySelectorAll(token)]
}
} catch (error) {
if (Debug.enabled) console.error(error)
}
}
})

return attrs
return elements.reduce((acc, ele) => {
return { ...extractDataAttributes(ele), ...acc }
}, {})
}

// Extracts all data attributes from a DOM element.
Expand All @@ -101,3 +140,27 @@ export const extractDataAttributes = element => {

return attrs
}

// Extracts all targets from a controller element.
// Reflex targets are later available in the triggered reflex class
//
export const extractTargets = (controllerElement, schema) => {
let targets = {}

const targetElements = controllerElement.querySelectorAll(
`[${schema.reflexTargetAttribute}]`
)

targetElements.forEach(target => {
const targetName = target.dataset.reflexTarget.split('.')[1]

targets[targetName] = {
selector: elementToXPath(target),
name: targetName,
dataset: extractElementDataset(target, schema.reflexDatasetAttribute),
attrs: extractElementAttributes(target)
}
})

return targets
}
3 changes: 2 additions & 1 deletion javascript/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export const defaultSchema = {
reflexAttribute: 'data-reflex',
reflexPermanentAttribute: 'data-reflex-permanent',
reflexRootAttribute: 'data-reflex-root',
reflexDatasetAttribute: 'data-reflex-dataset'
reflexDatasetAttribute: 'data-reflex-dataset',
reflexTargetAttribute: 'data-reflex-target'
}
8 changes: 5 additions & 3 deletions javascript/stimulus_reflex.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
import {
attributeValues,
extractElementAttributes,
extractElementDataset
extractElementDataset,
extractTargets
} from './attributes'
import Log from './log'
import Debug from './debug'
Expand Down Expand Up @@ -126,8 +127,8 @@ const register = (controller, options = {}) => {
let selectors = options['selectors'] || getReflexRoots(reflexElement)
if (typeof selectors === 'string') selectors = [selectors]
const resolveLate = options['resolveLate'] || false
const datasetAttribute = reflexes.app.schema.reflexDatasetAttribute
const dataset = extractElementDataset(reflexElement, datasetAttribute)
const dataset = extractElementDataset(reflexElement)
const targets = extractTargets(this.element, reflexes.app.schema)
const xpathController = elementToXPath(controllerElement)
const xpathElement = elementToXPath(reflexElement)
const data = {
Expand All @@ -136,6 +137,7 @@ const register = (controller, options = {}) => {
url,
attrs,
dataset,
targets,
selectors,
reflexId,
resolveLate,
Expand Down
18 changes: 18 additions & 0 deletions javascript/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,21 @@ export const XPathToElement = xpath => {
null
).singleNodeValue
}

export const XPathToArray = (xpath, reverse = false) => {
const snapshotList = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
)

const snapshots = []

for (let i = 0; i < snapshotList.snapshotLength; i++) {
snapshots.push(snapshotList.snapshotItem(i))
}

return reverse ? snapshots.reverse() : snapshots
}
33 changes: 29 additions & 4 deletions lib/stimulus_reflex/element.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
# frozen_string_literal: true

class StimulusReflex::Element < OpenStruct
attr_reader :attributes, :data_attributes
attr_reader :attributes, :data_attributes, :selector
attr_accessor :cable_ready

def initialize(data = {})
@attributes = HashWithIndifferentAccess.new(data["attrs"] || {})
@data_attributes = HashWithIndifferentAccess.new(data["dataset"] || {})
def initialize(attrs: {}, dataset: {}, selector: nil, cable_ready: nil)
@selector = selector
@cable_ready = cable_ready

@attributes = HashWithIndifferentAccess.new(attrs || {})
@data_attributes = HashWithIndifferentAccess.new(dataset || {})
all_attributes = @attributes.merge(@data_attributes)
super all_attributes.merge(all_attributes.transform_keys(&:underscore))
@data_attributes.transform_keys! { |key| key.delete_prefix "data-" }
Expand All @@ -22,4 +26,25 @@ def unsigned
def dataset
@dataset ||= OpenStruct.new(data_attributes.merge(data_attributes.transform_keys(&:underscore)))
end

def update
cable_ready.broadcast
end

def method_missing(method_name, *arguments, &block)
if cable_ready.respond_to?(method_name)
xpath = selector ? selector.starts_with?("//") : false
args = {selector: selector, xpath: xpath}.merge(arguments.first.to_h)

cable_ready.send(method_name.to_sym, args)

cable_ready
else
super
end
end

def respond_to_missing?(method_name)
cable_ready.respond_to?(method_name) || super
end
end
32 changes: 29 additions & 3 deletions lib/stimulus_reflex/reflex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class StimulusReflex::Reflex
include ActionView::Helpers::TagHelper

attr_accessor :payload
attr_reader :cable_ready, :channel, :url, :element, :selectors, :method_name, :broadcaster, :client_attributes, :logger
attr_reader :cable_ready, :channel, :url, :element, :data, :selectors, :method_name, :broadcaster, :client_attributes, :logger, :targets

alias_method :action_name, :method_name # for compatibility with controller libraries like Pundit that expect an action name

Expand All @@ -17,7 +17,7 @@ class StimulusReflex::Reflex
delegate :broadcast, :broadcast_message, to: :broadcaster
delegate :reflex_id, :reflex_controller, :xpath_controller, :xpath_element, :permanent_attribute_name, to: :client_attributes

def initialize(channel, url: nil, element: nil, selectors: [], method_name: nil, params: {}, client_attributes: {})
def initialize(channel, url: nil, data: nil, selectors: [], method_name: nil, params: {}, client_attributes: {}, targets: {})
if is_a? CableReady::Broadcaster
message = <<~MSG

Expand All @@ -31,15 +31,37 @@ def initialize(channel, url: nil, element: nil, selectors: [], method_name: nil,

@channel = channel
@url = url
@element = element
@data = data
@selectors = selectors
@targets = targets
@method_name = method_name
@params = params
@broadcaster = StimulusReflex::PageBroadcaster.new(self)
@logger = StimulusReflex::Logger.new(self)
@client_attributes = ClientAttributes.new(client_attributes)
@cable_ready = StimulusReflex::CableReadyChannels.new(stream_name)
@payload = {}

@element = StimulusReflex::Element.new(
attrs: data["attrs"],
dataset: data["dataset"],
selector: data["xpathElement"],
cable_ready: @cable_ready
)

@targets.each do |name, details|
target_name = "#{name.to_s.underscore}_target".to_sym

define_singleton_method(target_name) do
StimulusReflex::Element.new(
selector: details["selector"],
attrs: details["attr"],
dataset: details["dataset"],
cable_ready: @cable_ready
)
end
end

self.params
end

Expand Down Expand Up @@ -105,6 +127,10 @@ def controller
end
end

def controller_element
@controller_element ||= StimulusReflex::Element.new(selector: xpath_controller, cable_ready: @cable_ready)
end

def controller?
!!defined? @controller
end
Expand Down