stimulus_reflex 3.4.0 → 3.5.0.pre2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of stimulus_reflex might be problematic. Click here for more details.

Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +639 -484
  3. data/CODE_OF_CONDUCT.md +6 -0
  4. data/Gemfile.lock +99 -96
  5. data/LATEST +1 -0
  6. data/README.md +15 -14
  7. data/app/channels/stimulus_reflex/channel.rb +42 -73
  8. data/lib/generators/USAGE +1 -1
  9. data/lib/generators/stimulus_reflex/{config_generator.rb → initializer_generator.rb} +3 -3
  10. data/lib/generators/stimulus_reflex/templates/app/reflexes/%file_name%_reflex.rb.tt +3 -2
  11. data/lib/generators/stimulus_reflex/templates/app/reflexes/application_reflex.rb.tt +11 -4
  12. data/lib/generators/stimulus_reflex/templates/config/initializers/stimulus_reflex.rb +16 -1
  13. data/lib/stimulus_reflex.rb +11 -2
  14. data/lib/stimulus_reflex/broadcasters/broadcaster.rb +7 -4
  15. data/lib/stimulus_reflex/broadcasters/page_broadcaster.rb +2 -2
  16. data/lib/stimulus_reflex/broadcasters/selector_broadcaster.rb +20 -10
  17. data/lib/stimulus_reflex/broadcasters/update.rb +23 -0
  18. data/lib/stimulus_reflex/cable_ready_channels.rb +18 -3
  19. data/lib/stimulus_reflex/callbacks.rb +98 -0
  20. data/lib/stimulus_reflex/concern_enhancer.rb +37 -0
  21. data/lib/stimulus_reflex/configuration.rb +3 -1
  22. data/lib/stimulus_reflex/element.rb +48 -7
  23. data/lib/stimulus_reflex/policies/reflex_invocation_policy.rb +28 -0
  24. data/lib/stimulus_reflex/reflex.rb +49 -58
  25. data/lib/stimulus_reflex/reflex_data.rb +79 -0
  26. data/lib/stimulus_reflex/reflex_factory.rb +31 -0
  27. data/lib/stimulus_reflex/request_parameters.rb +19 -0
  28. data/lib/stimulus_reflex/{logger.rb → utils/logger.rb} +6 -8
  29. data/lib/stimulus_reflex/utils/sanity_checker.rb +210 -0
  30. data/lib/stimulus_reflex/version.rb +1 -1
  31. data/lib/tasks/stimulus_reflex/install.rake +54 -15
  32. data/package.json +7 -6
  33. data/stimulus_reflex.gemspec +8 -8
  34. data/test/broadcasters/broadcaster_test_case.rb +1 -1
  35. data/test/broadcasters/nothing_broadcaster_test.rb +5 -3
  36. data/test/broadcasters/page_broadcaster_test.rb +8 -4
  37. data/test/broadcasters/selector_broadcaster_test.rb +171 -55
  38. data/test/callbacks_test.rb +652 -0
  39. data/test/concern_enhancer_test.rb +54 -0
  40. data/test/element_test.rb +181 -0
  41. data/test/reflex_test.rb +2 -2
  42. data/test/test_helper.rb +22 -0
  43. data/test/tmp/app/reflexes/application_reflex.rb +10 -3
  44. data/test/tmp/app/reflexes/demo_reflex.rb +4 -2
  45. data/yarn.lock +1280 -1284
  46. metadata +47 -33
  47. data/lib/stimulus_reflex/sanity_checker.rb +0 -154
  48. data/tags +0 -156
@@ -14,12 +14,14 @@ module StimulusReflex
14
14
  end
15
15
 
16
16
  class Configuration
17
- attr_accessor :on_failed_sanity_checks, :parent_channel, :logging, :middleware
17
+ attr_accessor :on_failed_sanity_checks, :on_new_version_available, :on_missing_default_urls, :parent_channel, :logging, :middleware
18
18
 
19
19
  DEFAULT_LOGGING = proc { "[#{session_id}] #{operation_counter.magenta} #{reflex_info.green} -> #{selector.cyan} via #{mode} Morph (#{operation.yellow})" }
20
20
 
21
21
  def initialize
22
22
  @on_failed_sanity_checks = :exit
23
+ @on_new_version_available = :ignore
24
+ @on_missing_default_urls = :warn
23
25
  @parent_channel = "ApplicationCable::Channel"
24
26
  @logging = DEFAULT_LOGGING
25
27
  @middleware = ActionDispatch::MiddlewareStack.new
@@ -1,14 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class StimulusReflex::Element < OpenStruct
4
- attr_reader :attributes, :data_attributes
4
+ attr_reader :attrs, :data_attrs, :inner_html, :text_content
5
5
 
6
6
  def initialize(data = {})
7
- @attributes = HashWithIndifferentAccess.new(data["attrs"] || {})
8
- @data_attributes = HashWithIndifferentAccess.new(data["dataset"] || {})
9
- all_attributes = @attributes.merge(@data_attributes)
10
- super all_attributes.merge(all_attributes.transform_keys(&:underscore))
11
- @data_attributes.transform_keys! { |key| key.delete_prefix "data-" }
7
+ @attrs = HashWithIndifferentAccess.new(data["attrs"] || {})
8
+ @inner_html = data["inner_html"]
9
+ @text_content = data["text_content"]
10
+
11
+ datasets = data["dataset"] || {}
12
+ regular_dataset = datasets["dataset"] || {}
13
+ @data_attrs = build_data_attrs(regular_dataset, datasets["datasetAll"] || {})
14
+
15
+ super build_underscored(all_attributes)
16
+
17
+ @data_attrs.transform_keys! { |key| key.delete_prefix "data-" }
12
18
  end
13
19
 
14
20
  def signed
@@ -19,7 +25,42 @@ class StimulusReflex::Element < OpenStruct
19
25
  @unsigned ||= ->(accessor) { GlobalID::Locator.locate(dataset[accessor]) }
20
26
  end
21
27
 
28
+ def attributes
29
+ @attributes ||= OpenStruct.new(build_underscored(attrs))
30
+ end
31
+
22
32
  def dataset
23
- @dataset ||= OpenStruct.new(data_attributes.merge(data_attributes.transform_keys(&:underscore)))
33
+ @dataset ||= OpenStruct.new(build_underscored(data_attrs))
34
+ end
35
+
36
+ def to_dom_id
37
+ raise NoIDError.new "The element `morph` is called on must have a valid DOM ID" if id.blank?
38
+
39
+ "##{id}"
40
+ end
41
+
42
+ alias_method :data_attributes, :dataset
43
+
44
+ private
45
+
46
+ def all_attributes
47
+ @attrs.merge(@data_attrs)
48
+ end
49
+
50
+ def build_data_attrs(dataset, dataset_all)
51
+ dataset_all.transform_keys! { |key| "data-#{key.delete_prefix("data-").pluralize}" }
52
+
53
+ dataset.each { |key, value| dataset_all[key]&.prepend(value) }
54
+
55
+ data_attrs = dataset.merge(dataset_all)
56
+
57
+ HashWithIndifferentAccess.new(data_attrs || {})
58
+ end
59
+
60
+ def build_underscored(attrs)
61
+ attrs.merge(attrs.transform_keys(&:underscore))
62
+ end
63
+
64
+ class NoIDError < StandardError
24
65
  end
25
66
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusReflex
4
+ class ReflexMethodInvocationPolicy
5
+ attr_reader :arguments, :required_params, :optional_params
6
+
7
+ def initialize(method, arguments)
8
+ @arguments = arguments
9
+ @required_params = method.parameters.select { |(kind, _)| kind == :req }
10
+ @optional_params = method.parameters.select { |(kind, _)| kind == :opt }
11
+ end
12
+
13
+ def no_arguments?
14
+ arguments.size == 0 && required_params.size == 0
15
+ end
16
+
17
+ def arguments?
18
+ arguments.size >= required_params.size && arguments.size <= required_params.size + optional_params.size
19
+ end
20
+
21
+ def unknown?
22
+ return false if no_arguments?
23
+ return false if arguments?
24
+
25
+ true
26
+ end
27
+ end
28
+ end
@@ -1,49 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- ClientAttributes = Struct.new(:reflex_id, :reflex_controller, :xpath, :c_xpath, :permanent_attribute_name, keyword_init: true)
3
+ ClientAttributes = Struct.new(:reflex_id, :tab_id, :reflex_controller, :xpath_controller, :xpath_element, :permanent_attribute_name, keyword_init: true)
4
4
 
5
5
  class StimulusReflex::Reflex
6
6
  include ActiveSupport::Rescuable
7
- include ActiveSupport::Callbacks
8
-
9
- define_callbacks :process, skip_after_callbacks_if_terminated: true
10
-
11
- class << self
12
- def before_reflex(*args, &block)
13
- add_callback(:before, *args, &block)
14
- end
15
-
16
- def after_reflex(*args, &block)
17
- add_callback(:after, *args, &block)
18
- end
19
-
20
- def around_reflex(*args, &block)
21
- add_callback(:around, *args, &block)
22
- end
23
-
24
- private
25
-
26
- def add_callback(kind, *args, &block)
27
- options = args.extract_options!
28
- options.assert_valid_keys :if, :unless, :only, :except
29
- set_callback(*[:process, kind, args, normalize_callback_options!(options)].flatten, &block)
30
- end
31
-
32
- def normalize_callback_options!(options)
33
- normalize_callback_option! options, :only, :if
34
- normalize_callback_option! options, :except, :unless
35
- options
36
- end
37
-
38
- def normalize_callback_option!(options, from, to)
39
- if (from = options.delete(from))
40
- from_set = Array(from).map(&:to_s).to_set
41
- from = proc { |reflex| from_set.include? reflex.method_name }
42
- options[to] = Array(options[to]).unshift(from)
43
- end
44
- end
45
- end
7
+ include StimulusReflex::Callbacks
8
+ include ActionView::Helpers::TagHelper
9
+ include CableReady::Identifiable
46
10
 
11
+ attr_accessor :payload
47
12
  attr_reader :cable_ready, :channel, :url, :element, :selectors, :method_name, :broadcaster, :client_attributes, :logger
48
13
 
49
14
  alias_method :action_name, :method_name # for compatibility with controller libraries like Pundit that expect an action name
@@ -51,11 +16,20 @@ class StimulusReflex::Reflex
51
16
  delegate :connection, :stream_name, to: :channel
52
17
  delegate :controller_class, :flash, :session, to: :request
53
18
  delegate :broadcast, :broadcast_message, to: :broadcaster
54
- delegate :reflex_id, :reflex_controller, :xpath, :c_xpath, :permanent_attribute_name, to: :client_attributes
55
- delegate :render, to: :controller_class
56
- delegate :dom_id, to: "ActionView::RecordIdentifier"
19
+ delegate :reflex_id, :tab_id, :reflex_controller, :xpath_controller, :xpath_element, :permanent_attribute_name, to: :client_attributes
57
20
 
58
21
  def initialize(channel, url: nil, element: nil, selectors: [], method_name: nil, params: {}, client_attributes: {})
22
+ if is_a? CableReady::Broadcaster
23
+ message = <<~MSG
24
+
25
+ #{self.class.name} includes CableReady::Broadcaster, and you need to remove it.
26
+ Reflexes have their own CableReady interface. You can just assume that it's present.
27
+ See https://docs.stimulusreflex.com/rtfm/cableready#using-cableready-inside-a-reflex-action for more details.
28
+
29
+ MSG
30
+ raise TypeError.new(message.strip)
31
+ end
32
+
59
33
  @channel = channel
60
34
  @url = url
61
35
  @element = element
@@ -65,7 +39,8 @@ class StimulusReflex::Reflex
65
39
  @broadcaster = StimulusReflex::PageBroadcaster.new(self)
66
40
  @logger = StimulusReflex::Logger.new(self)
67
41
  @client_attributes = ClientAttributes.new(client_attributes)
68
- @cable_ready = StimulusReflex::CableReadyChannels.new(stream_name)
42
+ @cable_ready = StimulusReflex::CableReadyChannels.new(stream_name, reflex_id)
43
+ @payload = {}
69
44
  self.params
70
45
  end
71
46
 
@@ -98,17 +73,15 @@ class StimulusReflex::Reflex
98
73
 
99
74
  req = ActionDispatch::Request.new(env)
100
75
 
101
- path_params = Rails.application.routes.recognize_path_with_request(req, url, req.env[:extras] || {})
102
- path_params[:controller] = path_params[:controller].force_encoding("UTF-8")
103
- path_params[:action] = path_params[:action].force_encoding("UTF-8")
76
+ # fetch path params (controller, action, ...) and apply them
77
+ request_params = StimulusReflex::RequestParameters.new(params: @params, req: req, url: url)
78
+ req = request_params.apply!
104
79
 
105
- req.env.merge(ActionDispatch::Http::Parameters::PARAMETERS_KEY => path_params)
106
- req.env["action_dispatch.request.parameters"] = req.parameters.merge(@params)
107
- req.tap { |r| r.session.send :load! }
80
+ req
108
81
  end
109
82
  end
110
83
 
111
- def morph(selectors, html = "")
84
+ def morph(selectors, html = nil)
112
85
  case selectors
113
86
  when :page
114
87
  raise StandardError.new("Cannot call :page morph after :#{broadcaster.to_sym} morph") unless broadcaster.page?
@@ -123,16 +96,25 @@ class StimulusReflex::Reflex
123
96
  end
124
97
 
125
98
  def controller
126
- @controller ||= begin
127
- controller_class.new.tap do |c|
128
- c.instance_variable_set :"@stimulus_reflex", true
129
- instance_variables.each { |name| c.instance_variable_set name, instance_variable_get(name) }
130
- c.set_request! request
131
- c.set_response! controller_class.make_response!(request)
132
- end
99
+ @controller ||= controller_class.new.tap do |c|
100
+ c.instance_variable_set :@stimulus_reflex, true
101
+ c.set_request! request
102
+ c.set_response! controller_class.make_response!(request)
133
103
  end
104
+
105
+ instance_variables.each { |name| @controller.instance_variable_set name, instance_variable_get(name) }
106
+ @controller
107
+ end
108
+
109
+ def controller?
110
+ !!defined? @controller
134
111
  end
135
112
 
113
+ def render(*args)
114
+ controller_class.renderer.new(connection.env.merge("SCRIPT_NAME" => "")).render(*args)
115
+ end
116
+
117
+ # Invoke the reflex action specified by `name` and run all callbacks
136
118
  def process(name, *args)
137
119
  reflex_invoked = false
138
120
  result = run_callbacks(:process) {
@@ -156,4 +138,13 @@ class StimulusReflex::Reflex
156
138
  def params
157
139
  @_params ||= ActionController::Parameters.new(request.parameters)
158
140
  end
141
+
142
+ # morphdom needs content to be wrapped in an element with the same id when children_only: true
143
+ # Oddly, it doesn't matter if the target element is a div! See: https://docs.stimulusreflex.com/appendices/troubleshooting#different-element-type-altogether-who-cares-so-long-as-the-css-selector-matches
144
+ # Used internally to allow automatic partial collection rendering, but also useful to library users
145
+ # eg. `morph dom_id(@posts), render_collection(@posts)`
146
+ def render_collection(resource, content = nil)
147
+ content ||= render(resource)
148
+ tag.div(content.html_safe, id: dom_id(resource).from(1))
149
+ end
159
150
  end
@@ -0,0 +1,79 @@
1
+ class StimulusReflex::ReflexData
2
+ attr_reader :data
3
+
4
+ def initialize(data)
5
+ @data = data
6
+ end
7
+
8
+ def reflex_name
9
+ reflex_name = target.split("#").first
10
+ reflex_name = reflex_name.camelize
11
+ reflex_name.end_with?("Reflex") ? reflex_name : "#{reflex_name}Reflex"
12
+ end
13
+
14
+ def selectors
15
+ selectors = (data["selectors"] || []).select(&:present?)
16
+ selectors = data["selectors"] = ["body"] if selectors.blank?
17
+ selectors
18
+ end
19
+
20
+ def target
21
+ data["target"].to_s
22
+ end
23
+
24
+ def method_name
25
+ target.split("#").second
26
+ end
27
+
28
+ def arguments
29
+ (data["args"] || []).map { |arg| object_with_indifferent_access arg } || []
30
+ end
31
+
32
+ def url
33
+ data["url"].to_s
34
+ end
35
+
36
+ def element
37
+ StimulusReflex::Element.new(data)
38
+ end
39
+
40
+ def permanent_attribute_name
41
+ data["permanentAttributeName"]
42
+ end
43
+
44
+ def form_data
45
+ Rack::Utils.parse_nested_query(data["formData"])
46
+ end
47
+
48
+ def form_params
49
+ form_data.deep_merge(data["params"] || {})
50
+ end
51
+
52
+ def reflex_id
53
+ data["reflexId"]
54
+ end
55
+
56
+ def tab_id
57
+ data["tabId"]
58
+ end
59
+
60
+ def xpath_controller
61
+ data["xpathController"]
62
+ end
63
+
64
+ def xpath_element
65
+ data["xpathElement"]
66
+ end
67
+
68
+ def reflex_controller
69
+ data["reflexController"]
70
+ end
71
+
72
+ private
73
+
74
+ def object_with_indifferent_access(object)
75
+ return object.with_indifferent_access if object.respond_to?(:with_indifferent_access)
76
+ object.map! { |obj| object_with_indifferent_access obj } if object.is_a?(Array)
77
+ object
78
+ end
79
+ end
@@ -0,0 +1,31 @@
1
+ class StimulusReflex::ReflexFactory
2
+ class << self
3
+ attr_reader :reflex_data
4
+
5
+ def create_reflex_from_data(channel, reflex_data)
6
+ @reflex_data = reflex_data
7
+ reflex_class.new(channel,
8
+ url: reflex_data.url,
9
+ element: reflex_data.element,
10
+ selectors: reflex_data.selectors,
11
+ method_name: reflex_data.method_name,
12
+ params: reflex_data.form_params,
13
+ client_attributes: {
14
+ reflex_id: reflex_data.reflex_id,
15
+ tab_id: reflex_data.tab_id,
16
+ xpath_controller: reflex_data.xpath_controller,
17
+ xpath_element: reflex_data.xpath_element,
18
+ reflex_controller: reflex_data.reflex_controller,
19
+ permanent_attribute_name: reflex_data.permanent_attribute_name
20
+ })
21
+ end
22
+
23
+ def reflex_class
24
+ reflex_data.reflex_name.constantize.tap { |klass| raise ArgumentError.new("#{reflex_name} is not a StimulusReflex::Reflex") unless is_reflex?(klass) }
25
+ end
26
+
27
+ def is_reflex?(klass)
28
+ klass.ancestors.include? StimulusReflex::Reflex
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ module StimulusReflex
2
+ class RequestParameters
3
+ def initialize(params:, req:, url:)
4
+ @params = params
5
+ @req = req
6
+ @url = url
7
+ end
8
+
9
+ def apply!
10
+ path_params = Rails.application.routes.recognize_path_with_request(@req, @url, @req.env[:extras] || {})
11
+ path_params[:controller] = path_params[:controller].force_encoding("UTF-8")
12
+ path_params[:action] = path_params[:action].force_encoding("UTF-8")
13
+
14
+ @req.env.merge(ActionDispatch::Http::Parameters::PARAMETERS_KEY => path_params)
15
+ @req.env["action_dispatch.request.parameters"] = @req.parameters.merge(@params)
16
+ @req.tap { |r| r.session.send :load! }
17
+ end
18
+ end
19
+ end
@@ -12,12 +12,10 @@ module StimulusReflex
12
12
  def print
13
13
  return unless config_logging.instance_of?(Proc)
14
14
 
15
- puts
16
15
  reflex.broadcaster.operations.each do
17
16
  puts instance_eval(&config_logging) + "\e[0m"
18
17
  @current_operation += 1
19
18
  end
20
- puts
21
19
  end
22
20
 
23
21
  private
@@ -79,22 +77,22 @@ module StimulusReflex
79
77
  Time.now.strftime("%Y-%m-%d %H:%M:%S")
80
78
  end
81
79
 
82
- def method_missing method
83
- return send(method.to_sym) if private_instance_methods.include?(method.to_sym)
80
+ def method_missing(name, *args)
81
+ return send(name) if private_instance_methods.include?(name.to_sym)
84
82
 
85
83
  reflex.connection.identifiers.each do |identifier|
86
84
  ident = reflex.connection.send(identifier)
87
- return ident.send(method) if ident.respond_to?(:attributes) && ident.attributes.key?(method.to_s)
85
+ return ident.send(name) if ident.respond_to?(:attributes) && ident.attributes.key?(name.to_s)
88
86
  end
89
87
  "-"
90
88
  end
91
89
 
92
- def respond_to_missing? method
93
- return true if private_instance_methods.include?(method.to_sym)
90
+ def respond_to_missing?(name, include_all)
91
+ return true if private_instance_methods.include?(name.to_sym)
94
92
 
95
93
  reflex.connection.identifiers.each do |identifier|
96
94
  ident = reflex.connection.send(identifier)
97
- return true if ident.respond_to?(:attributes) && ident.attributes.key?(method.to_s)
95
+ return true if ident.respond_to?(:attributes) && ident.attributes.key?(name.to_s)
98
96
  end
99
97
  false
100
98
  end