phlex-reactive 0.13.4 → 0.14.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1079 -630
- data/README.md +612 -17
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1582 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +86 -5821
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3706 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +39 -9
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +188 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +63 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +47 -2
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +27 -7
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- metadata +38 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Phlex
|
|
8
|
+
module Reactive
|
|
9
|
+
# Privately cacheable lazy renders (issue #277): the signed id and URL of a
|
|
10
|
+
# `reactive_lazy cache: { max_age: }` component's GET fragment.
|
|
11
|
+
#
|
|
12
|
+
# The id is a DETERMINISTIC signature over the component's identity payload
|
|
13
|
+
# — no expiry, no actor binding, purpose-scoped — so the same component
|
|
14
|
+
# renders the same URL on every page view and the browser's private HTTP
|
|
15
|
+
# cache can reuse the response. It names NO viewer: who the render is for
|
|
16
|
+
# always comes from the session at the endpoint. (Like the identity token it
|
|
17
|
+
# does carry the signed state / record gid — signed, not encrypted.) The
|
|
18
|
+
# purpose makes it disjoint
|
|
19
|
+
# from the identity and defer tokens: a fragment id can't run an action or
|
|
20
|
+
# reach the defer endpoint, and neither of those tokens resolves here.
|
|
21
|
+
#
|
|
22
|
+
# Two unsigned query parameters only shape the browser's cache KEY — the
|
|
23
|
+
# render never reads them:
|
|
24
|
+
# v — a digest of the component's reactive_cache_version (content changed)
|
|
25
|
+
# u — a KEYED digest of its reactive_cache_viewer (who the render is
|
|
26
|
+
# for). The endpoint recomputes it in the CURRENT session and makes
|
|
27
|
+
# the reply cacheable only when it matches, so a URL can only ever
|
|
28
|
+
# hold the render of the viewer it names. A blank viewer names nobody:
|
|
29
|
+
# no `u`, and the reply falls back to `Vary: Cookie`.
|
|
30
|
+
module Fragment
|
|
31
|
+
# The purpose of the previous id format (still verified, see verify).
|
|
32
|
+
PURPOSE = "phlex-reactive/fragment"
|
|
33
|
+
# The purpose the current id's MAC is keyed under (issue #306).
|
|
34
|
+
ID_PURPOSE = "phlex-reactive/fragment-id"
|
|
35
|
+
# 128 bits of MAC: 22 base64url characters.
|
|
36
|
+
MAC_BYTES = 16
|
|
37
|
+
MAC_LENGTH = 22
|
|
38
|
+
BASE64URL = /\A[A-Za-z0-9_-]+\z/
|
|
39
|
+
VIEWER_PURPOSE = "phlex-reactive/fragment-viewer"
|
|
40
|
+
# A viewer is an identity — an id, or a few of them. These bound how far
|
|
41
|
+
# a collection is walked, so no value can make the shell render slow.
|
|
42
|
+
VIEWER_PART_LIMIT = 32
|
|
43
|
+
VIEWER_NODE_LIMIT = 64
|
|
44
|
+
# Components already warned about (by class name): once per component.
|
|
45
|
+
VIEWER_WARNED = Concurrent::Map.new
|
|
46
|
+
|
|
47
|
+
# A reactive_cache_viewer value that cannot name a viewer. Internal: it
|
|
48
|
+
# carries the reason from the walk to viewer_param, which logs it.
|
|
49
|
+
class UnusableViewer < StandardError; end
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# The signed id of an identity payload (issue #306):
|
|
54
|
+
#
|
|
55
|
+
# base64url(JSON payload) + base64url(128-bit MAC)
|
|
56
|
+
#
|
|
57
|
+
# The payload is encoded ONCE and the MAC is a fixed 22 characters, so the
|
|
58
|
+
# id is one URL path segment of base64url characters with no separator
|
|
59
|
+
# (a "." would read as a format suffix to the router). The MAC is keyed
|
|
60
|
+
# through the configured verifier — a digest of its signature over the
|
|
61
|
+
# encoded payload under its own purpose, the same construction as the
|
|
62
|
+
# viewer digest — so it works with any verifier the app configured and
|
|
63
|
+
# cannot be computed without the app's secret. Deterministic: the same
|
|
64
|
+
# identity always renders the same URL, which is what lets the browser's
|
|
65
|
+
# cache hit.
|
|
66
|
+
def sign(payload)
|
|
67
|
+
data = Base64.urlsafe_encode64(JSON.generate(payload.merge("v" => TOKEN_VERSION)), padding: false)
|
|
68
|
+
"#{data}#{compact_mac(data)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The verified, version-upgraded payload, or nil — tampered, a token of
|
|
72
|
+
# another purpose, or no fragment id at all. The current format is tried
|
|
73
|
+
# first, then the previous one: a browser may still hold a page or a
|
|
74
|
+
# cached fragment URL minted before #306 (for up to
|
|
75
|
+
# fragment_cache_max_age_limit).
|
|
76
|
+
def verify(id)
|
|
77
|
+
id = id.to_s
|
|
78
|
+
payload = verify_compact(id) || verify_legacy(id)
|
|
79
|
+
payload && Phlex::Reactive.upgrade_token(payload)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The MAC is checked BEFORE the data is decoded or parsed. Anything but
|
|
83
|
+
# base64url characters is not an id (and never reaches the verifier).
|
|
84
|
+
def verify_compact(id)
|
|
85
|
+
return nil unless id.length > MAC_LENGTH && id.match?(BASE64URL)
|
|
86
|
+
|
|
87
|
+
data = id[0...-MAC_LENGTH]
|
|
88
|
+
return nil unless ActiveSupport::SecurityUtils.secure_compare(compact_mac(data), id[-MAC_LENGTH..])
|
|
89
|
+
|
|
90
|
+
payload = JSON.parse(Base64.urlsafe_decode64(data))
|
|
91
|
+
payload.is_a?(::Hash) ? payload : nil
|
|
92
|
+
rescue ::ArgumentError, JSON::ParserError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The previous format: the verifier's token (Base64 + "--" + digest)
|
|
97
|
+
# wrapped in urlsafe Base64. Kept for one release (issue #306).
|
|
98
|
+
def verify_legacy(id)
|
|
99
|
+
Phlex::Reactive.verifier.verified(Base64.urlsafe_decode64(id), purpose: PURPOSE)
|
|
100
|
+
rescue ::ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `data` is pinned to US-ASCII (it is base64url): the id is minted as a
|
|
105
|
+
# US-ASCII String but arrives as a UTF-8 param, and a Marshal-serializing
|
|
106
|
+
# verifier (load_defaults < 7.1) signs the encoding along with the bytes.
|
|
107
|
+
def compact_mac(data)
|
|
108
|
+
data = data.dup.force_encoding(Encoding::US_ASCII)
|
|
109
|
+
digest_param(Digest::SHA256.digest(Phlex::Reactive.verifier.generate(data, purpose: ID_PURPOSE)), MAC_BYTES)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The fragment URL the shell renders into data-reactive-defer-src:
|
|
113
|
+
# <fragment_path>/<signed id>[?v=<version digest>][&u=<viewer digest>].
|
|
114
|
+
# `version` is the component's reactive_cache_version (nil → no `v`);
|
|
115
|
+
# `viewer` is its ALREADY-DIGESTED viewer_param (nil → no `u`).
|
|
116
|
+
def src(payload, version: nil, viewer: nil)
|
|
117
|
+
query = []
|
|
118
|
+
query << "v=#{version_param(version)}" unless version.nil?
|
|
119
|
+
query << "u=#{viewer}" if viewer
|
|
120
|
+
path = "#{Phlex::Reactive.fragment_path}/#{sign(payload)}"
|
|
121
|
+
query.empty? ? path : "#{path}?#{query.join("&")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `v` only varies the browser's cache KEY — the endpoint never reads it —
|
|
125
|
+
# so it is digested: short, opaque, and safe for any value an app returns
|
|
126
|
+
# (a Time keeps its sub-second precision, a record its cache key). 64
|
|
127
|
+
# bits as 11 base64url characters: a cache-buster, not a secret.
|
|
128
|
+
def version_param(version)
|
|
129
|
+
key = version.respond_to?(:utc) ? version.utc.strftime("%Y%m%d%H%M%S%N") : version
|
|
130
|
+
digest_param(Digest::SHA256.digest(ActiveSupport::Cache.expand_cache_key(key)), 8)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def digest_param(digest, bytes)
|
|
134
|
+
Base64.urlsafe_encode64(digest.byteslice(0, bytes), padding: false)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The `u` of a reactive_cache_viewer value, or nil when the value names
|
|
138
|
+
# NO viewer — nil, false, a blank String, or an Array with any blank part
|
|
139
|
+
# or Hash with any blank leaf (`[Current.user&.id, locale]` signed out),
|
|
140
|
+
# or a value whose cache key is empty. A blank viewer must never
|
|
141
|
+
# become a shared "anonymous" key: every signed-out or mis-resolved
|
|
142
|
+
# session would then store and read ONE url with no Vary. nil sends the
|
|
143
|
+
# component back to the default mode (`Vary: Cookie`) for that render.
|
|
144
|
+
#
|
|
145
|
+
# KEYED: the digest is taken over the verifier's signature of the value,
|
|
146
|
+
# so it can be neither reversed to the value (a user id is a small space)
|
|
147
|
+
# nor computed for someone else without the app's secret. 128 bits, as
|
|
148
|
+
# 22 base64url characters.
|
|
149
|
+
#
|
|
150
|
+
# NEVER RAISES and never takes long, whatever the hook returned: this runs
|
|
151
|
+
# inside the host page's render. A value that cannot be turned into a key
|
|
152
|
+
# (it raises, it is endless, it is huge) names nobody — the fail-closed
|
|
153
|
+
# default mode — and is logged once per component (`owner`, its class).
|
|
154
|
+
def viewer_param(viewer, owner: nil)
|
|
155
|
+
return nil unless viewer_named?(viewer)
|
|
156
|
+
|
|
157
|
+
signed = Phlex::Reactive.verifier.generate(ActiveSupport::Cache.expand_cache_key(viewer),
|
|
158
|
+
purpose: VIEWER_PURPOSE)
|
|
159
|
+
digest_param(Digest::SHA256.digest(signed), 16)
|
|
160
|
+
rescue UnusableViewer => e
|
|
161
|
+
warn_unusable_viewer(owner, e.message)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn_unusable_viewer(owner, "#{e.class}: #{e.message}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def viewer_named?(viewer)
|
|
167
|
+
named_within?(viewer, [VIEWER_NODE_LIMIT])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Every LEAF must name something. A collection — anything
|
|
171
|
+
# expand_cache_key walks with to_a: an Array, a Hash (keys and values), a
|
|
172
|
+
# Set, a Struct, a sized Enumerator, nested — with one blank part is
|
|
173
|
+
# unnamed, and so is a value whose cache key expands to nothing (an
|
|
174
|
+
# object with a blank to_param). 0 is a viewer.
|
|
175
|
+
#
|
|
176
|
+
# Bounded: a Range is never walked, a collection of more than
|
|
177
|
+
# VIEWER_PART_LIMIT parts or a walk of more than VIEWER_NODE_LIMIT values
|
|
178
|
+
# (a self-referential Array, a to_a that returns itself) is unnamed.
|
|
179
|
+
# `budget` is a one-element counter shared by the whole walk.
|
|
180
|
+
def named_within?(value, budget)
|
|
181
|
+
return false if value.nil? || value == false
|
|
182
|
+
raise UnusableViewer, "a Range is not an identity" if value.is_a?(::Range)
|
|
183
|
+
raise UnusableViewer, "too deep or self-referential" if (budget[0] -= 1).negative?
|
|
184
|
+
return viewer_leaf?(value) unless value.respond_to?(:to_a) && !value.respond_to?(:cache_key)
|
|
185
|
+
|
|
186
|
+
size = value.respond_to?(:size) ? value.size : nil
|
|
187
|
+
raise UnusableViewer, "a collection of unknown or excessive size" unless viewer_size_ok?(size, value)
|
|
188
|
+
|
|
189
|
+
parts = value.to_a
|
|
190
|
+
raise UnusableViewer, "more than #{VIEWER_PART_LIMIT} parts" if parts.size > VIEWER_PART_LIMIT
|
|
191
|
+
|
|
192
|
+
parts.any? && parts.all? { named_within?(it, budget) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def viewer_leaf?(value)
|
|
196
|
+
value.present? && ActiveSupport::Cache.expand_cache_key(value).present?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# An Enumerator must report a finite size; anything sized must be small.
|
|
200
|
+
def viewer_size_ok?(size, value)
|
|
201
|
+
return size <= VIEWER_PART_LIMIT if size.is_a?(::Integer)
|
|
202
|
+
|
|
203
|
+
!value.is_a?(::Enumerator) && size.nil?
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Logs why — once per COMPONENT, naming it, since the cause is that
|
|
207
|
+
# component's hook — and answers nil: "this value names nobody".
|
|
208
|
+
def warn_unusable_viewer(owner, reason)
|
|
209
|
+
name = owner.respond_to?(:name) ? owner.name.to_s : owner.to_s
|
|
210
|
+
return nil if VIEWER_WARNED.put_if_absent(name, true)
|
|
211
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
212
|
+
|
|
213
|
+
::Rails.logger&.warn(
|
|
214
|
+
"[phlex-reactive] #{name.presence || "a component"}#reactive_cache_viewer returned a value that " \
|
|
215
|
+
"cannot name a viewer (#{reason}) — treated as no viewer (Vary: Cookie). Return an id, or a small " \
|
|
216
|
+
"Array of ids."
|
|
217
|
+
)
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Test seam: let the once-per-component warnings fire again.
|
|
222
|
+
def reset_viewer_warning!
|
|
223
|
+
VIEWER_WARNED.clear
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The max-age (seconds) the endpoint answers with: the component's
|
|
227
|
+
# declared value, capped by Phlex::Reactive.fragment_cache_max_age_limit.
|
|
228
|
+
def max_age_for(component_class)
|
|
229
|
+
[component_class.reactive_lazy_cache.fetch(:max_age), Phlex::Reactive.fragment_cache_max_age_limit.to_i].min
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -15,7 +15,13 @@ module Phlex
|
|
|
15
15
|
# A plain-text table of every component × action, or a JSON array when
|
|
16
16
|
# `format` is :json. One row per action: component, action, params,
|
|
17
17
|
# file:line, authorization heuristic.
|
|
18
|
-
|
|
18
|
+
# `unverified_only:` keeps just the actions whose authorization_state is
|
|
19
|
+
# :none (the review queue) and drops components left with none.
|
|
20
|
+
def actions(components, format: :text, unverified_only: false)
|
|
21
|
+
if unverified_only
|
|
22
|
+
components = unverified(components)
|
|
23
|
+
return "no unverified actions" if components.empty? && format != :json
|
|
24
|
+
end
|
|
19
25
|
return actions_json(components) if format == :json
|
|
20
26
|
|
|
21
27
|
actions_text(components)
|
|
@@ -34,6 +40,13 @@ module Phlex
|
|
|
34
40
|
lines.join("\n")
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
def unverified(components)
|
|
44
|
+
components.filter_map do
|
|
45
|
+
actions = it.actions.select { it.authorization_state == :none }
|
|
46
|
+
it.with(actions:) if actions.any?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
37
50
|
# -- plain text -------------------------------------------------------
|
|
38
51
|
|
|
39
52
|
def actions_text(components)
|
|
@@ -110,7 +123,9 @@ module Phlex
|
|
|
110
123
|
name: action.name,
|
|
111
124
|
params: action.params,
|
|
112
125
|
source_location: location_str(action.source_location),
|
|
113
|
-
authorization_call_detected: action.authorization_call_detected
|
|
126
|
+
authorization_call_detected: action.authorization_call_detected?,
|
|
127
|
+
authorization: action.authorization_state.to_s,
|
|
128
|
+
authorization_skip: action.authorization_skip&.to_s
|
|
114
129
|
}
|
|
115
130
|
end
|
|
116
131
|
|
|
@@ -131,8 +146,14 @@ module Phlex
|
|
|
131
146
|
end
|
|
132
147
|
|
|
133
148
|
# The authorization heuristic as a short label — advisory only.
|
|
149
|
+
# "authorized*" (call detected), "skipped" / "skipped (class)" (declared
|
|
150
|
+
# skip_verify_authorized), "unverified" (neither).
|
|
134
151
|
def auth_str(action)
|
|
135
|
-
action.
|
|
152
|
+
case action.authorization_state
|
|
153
|
+
when :detected then "authorized*"
|
|
154
|
+
when :skipped then action.authorization_skip == :class ? "skipped (class)" : "skipped"
|
|
155
|
+
else "unverified"
|
|
156
|
+
end
|
|
136
157
|
end
|
|
137
158
|
end
|
|
138
159
|
end
|
|
@@ -31,7 +31,10 @@ module Phlex
|
|
|
31
31
|
# of the definition finds a call to any configured
|
|
32
32
|
# authorization method or mark_authorized!. A helper
|
|
33
33
|
# may authorize indirectly, so this is advisory ONLY.
|
|
34
|
-
|
|
34
|
+
# * authorization_skip — :class (a bare skip_verify_authorized, own or
|
|
35
|
+
# inherited), :action (this action is named in one),
|
|
36
|
+
# or nil. The author's declared opt-out (issue #278).
|
|
37
|
+
ActionInfo = Data.define(:name, :params, :source_location, :definition, :authorization_skip) do
|
|
35
38
|
# Was any authorization method (or mark_authorized!) called directly in
|
|
36
39
|
# the method body? Heuristic — a Prism scan of `definition`. False when
|
|
37
40
|
# the definition is unavailable.
|
|
@@ -40,6 +43,14 @@ module Phlex
|
|
|
40
43
|
|
|
41
44
|
Inspector.authorization_call?(definition)
|
|
42
45
|
end
|
|
46
|
+
|
|
47
|
+
# :detected (a call was found), :skipped (declared skip_verify_authorized)
|
|
48
|
+
# or :none (neither — the review queue). A detected call wins over a skip.
|
|
49
|
+
def authorization_state
|
|
50
|
+
return :detected if authorization_call_detected?
|
|
51
|
+
|
|
52
|
+
authorization_skip ? :skipped : :none
|
|
53
|
+
end
|
|
43
54
|
end
|
|
44
55
|
|
|
45
56
|
# One reactive component's introspection.
|
|
@@ -164,10 +175,20 @@ module Phlex
|
|
|
164
175
|
name: action.name,
|
|
165
176
|
params: action.params,
|
|
166
177
|
source_location: location,
|
|
167
|
-
definition: (extract_definition(location) if location)
|
|
178
|
+
definition: (extract_definition(location) if location),
|
|
179
|
+
authorization_skip: authorization_skip(klass, action.name)
|
|
168
180
|
)
|
|
169
181
|
end
|
|
170
182
|
|
|
183
|
+
# How `name` opts out of verify_authorized: :class for a bare skip (own
|
|
184
|
+
# or inherited), :action when listed by name, else nil. Resolved through
|
|
185
|
+
# the same Registry readers the runtime guard uses.
|
|
186
|
+
def authorization_skip(klass, name)
|
|
187
|
+
return :class if klass.reactive_skip_all_authorization?
|
|
188
|
+
|
|
189
|
+
:action if klass.reactive_skip_authorization_actions.include?(name.to_sym)
|
|
190
|
+
end
|
|
191
|
+
|
|
171
192
|
# The method's [file, line], or nil when the declared method is missing
|
|
172
193
|
# (an undeclared/typo'd action — the endpoint would 500 on public_send).
|
|
173
194
|
def method_location(klass, name)
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -196,9 +196,24 @@ module Phlex
|
|
|
196
196
|
# "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
|
|
197
197
|
# reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
|
|
198
198
|
# submit would force-submit every subscriber's form.
|
|
199
|
+
#
|
|
200
|
+
# submitter: (issue #319) — a CSS selector for the submit control to
|
|
201
|
+
# submit THROUGH (form.requestSubmit(submitter)), so the request carries
|
|
202
|
+
# its name=value — the bulk-action "Delete" that posts action=delete. It
|
|
203
|
+
# resolves with the op's own scoping; one that isn't a submit control of
|
|
204
|
+
# that form warns in the browser and falls back to a plain submit.
|
|
205
|
+
# js.submit("#bulk", submitter: "#delete-submit")
|
|
206
|
+
|
|
207
|
+
def submit(to = :root, global: false, submitter: nil)
|
|
208
|
+
args = target_args(to, global:)
|
|
209
|
+
return append("submit", args) if submitter.nil?
|
|
210
|
+
|
|
211
|
+
unless submitter.is_a?(String) && !submitter.empty?
|
|
212
|
+
raise ArgumentError,
|
|
213
|
+
"js.submit submitter: must be a CSS selector string naming a submit control, got #{submitter.inspect}"
|
|
214
|
+
end
|
|
199
215
|
|
|
200
|
-
|
|
201
|
-
append("submit", target_args(to, global:))
|
|
216
|
+
append("submit", args.merge("submitter" => submitter).freeze)
|
|
202
217
|
end
|
|
203
218
|
|
|
204
219
|
# --- Clipboard-source paste (issue #228) ---
|
|
@@ -244,6 +259,36 @@ module Phlex
|
|
|
244
259
|
append("text", args.freeze)
|
|
245
260
|
end
|
|
246
261
|
|
|
262
|
+
# --- Tick or untick a checkbox group (issue #342) ---
|
|
263
|
+
#
|
|
264
|
+
# check_group(group, checked = true) — set every OWNED box of the group
|
|
265
|
+
# (the #15 rule: a nested root's boxes are its own) the way a
|
|
266
|
+
# reactive_select_all header does: every box first, then `input` +
|
|
267
|
+
# `change` on each one it flipped, then ONE re-sync of the header, count,
|
|
268
|
+
# show and enable bindings. The group is a checkbox name resolved like
|
|
269
|
+
# reactive_select_all's: a bare name takes reactive_scope, a bracketed one
|
|
270
|
+
# ("ids[]") is used verbatim. `global: true` is for a trigger outside the
|
|
271
|
+
# root that owns the group: EVERY connected reactive root on the page flips the
|
|
272
|
+
# boxes of that name it owns (nested roots included). ACTOR-ONLY like focus/submit: a broadcast that cleared
|
|
273
|
+
# every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
|
|
274
|
+
# button(**on_client(:click, js.check_group("ids[]", false))) { "✕" }
|
|
275
|
+
|
|
276
|
+
# Positional `checked` on purpose: js.check_group("ids[]", false) reads as
|
|
277
|
+
# the op it is (the issue #342 API).
|
|
278
|
+
def check_group(group, checked = true, global: false) # rubocop:disable Style/OptionalBooleanParameter
|
|
279
|
+
name = -group.to_s
|
|
280
|
+
if name.strip.empty?
|
|
281
|
+
raise ArgumentError, "check_group needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
|
|
282
|
+
end
|
|
283
|
+
unless [true, false].include?(checked)
|
|
284
|
+
raise ArgumentError, "check_group(#{name.inspect}) takes true or false, got #{checked.inspect}"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
args = { "to" => ROOT_SENTINEL, "group" => name, "checked" => checked }
|
|
288
|
+
args["global"] = true if global
|
|
289
|
+
append("check_group", args.freeze)
|
|
290
|
+
end
|
|
291
|
+
|
|
247
292
|
# --- Dispatch a bubbling CustomEvent (issue #96) ---
|
|
248
293
|
#
|
|
249
294
|
# dispatch(name, to: nil, detail: {}) — emit a bubbling CustomEvent so other
|
|
@@ -15,7 +15,9 @@ module Phlex
|
|
|
15
15
|
action with its param schema, source file:line, and a heuristic
|
|
16
16
|
authorization status (whether an authorization method or
|
|
17
17
|
mark_authorized! call was detected in the body — advisory only, since a
|
|
18
|
-
helper may authorize indirectly)
|
|
18
|
+
helper may authorize indirectly), plus `authorization` — detected,
|
|
19
|
+
skipped (declared skip_verify_authorized; `authorization_skip` says
|
|
20
|
+
class or action) or none. Pass `component:` to scope to one
|
|
19
21
|
component (exact name). Read-only — schemas and paths, never runtime
|
|
20
22
|
values.
|
|
21
23
|
DESC
|
|
@@ -15,7 +15,8 @@ module Phlex
|
|
|
15
15
|
Fuzzy-find reactive components by name (exact > prefix > substring >
|
|
16
16
|
subsequence, on both the demodulized and full name). Returns the ranked
|
|
17
17
|
matches, each with its actions' param schema, source location,
|
|
18
|
-
authorization
|
|
18
|
+
authorization (detected / skipped / none, plus the skip form), and the
|
|
19
|
+
full method-definition SOURCE
|
|
19
20
|
(extracted with Prism). Read-only.
|
|
20
21
|
|
|
21
22
|
NOTE: unlike the other tools (which report names/paths/schemas only),
|
|
@@ -53,6 +54,8 @@ module Phlex
|
|
|
53
54
|
params: action.params,
|
|
54
55
|
source_location: Phlex::Reactive::Inspector::Report.location_str(action.source_location),
|
|
55
56
|
authorization_call_detected: action.authorization_call_detected?,
|
|
57
|
+
authorization: action.authorization_state.to_s,
|
|
58
|
+
authorization_skip: action.authorization_skip&.to_s,
|
|
56
59
|
# The full method SOURCE — this is the one field that exposes an
|
|
57
60
|
# action body verbatim (see the tool description). A secret hardcoded
|
|
58
61
|
# in an action would surface here; keep secrets out of action bodies.
|
|
@@ -334,36 +334,10 @@ module Phlex
|
|
|
334
334
|
value.is_a?(Hash) || value.respond_to?(:to_unsafe_h)
|
|
335
335
|
end
|
|
336
336
|
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
def to_param_hash(value)
|
|
341
|
-
flat =
|
|
342
|
-
if value.respond_to?(:to_unsafe_h)
|
|
343
|
-
value.to_unsafe_h.stringify_keys
|
|
344
|
-
elsif value.is_a?(Hash)
|
|
345
|
-
value.stringify_keys
|
|
346
|
-
else
|
|
347
|
-
return {}
|
|
348
|
-
end
|
|
349
|
-
|
|
350
|
-
expand_bracket_keys(flat)
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
# The client's #collectFields keeps a form input's name verbatim, so a
|
|
354
|
-
# Rails Form(model: @invoice) posts FLAT bracketed keys like
|
|
355
|
-
# "invoice[date]". Coercion does exact-key matching, so without this a
|
|
356
|
-
# nested schema (params: { invoice: { date: … } }) never finds "invoice"
|
|
357
|
-
# and drops everything. Expand "invoice[date]" => "2026-01-02" into
|
|
358
|
-
# { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
|
|
359
|
-
# the Rails index-hash form coerce_array already understands — deep-merging
|
|
360
|
-
# so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
|
|
361
|
-
# brackets and already-nested values pass through untouched.
|
|
362
|
-
def expand_bracket_keys(flat)
|
|
363
|
-
flat.each_with_object({}) do |(key, value), out|
|
|
364
|
-
deep_assign(out, bracket_path(key), value)
|
|
365
|
-
end
|
|
366
|
-
end
|
|
337
|
+
# Each nested hash is unwrapped and expanded by the class-level expand
|
|
338
|
+
# below — the same expansion the endpoint runs before peeling a
|
|
339
|
+
# reactive_scope (issue #337), so both read one shape.
|
|
340
|
+
def to_param_hash(value) = self.class.expand(value)
|
|
367
341
|
|
|
368
342
|
# Matches each bracket segment in "items_attributes][0][qty]" — the part
|
|
369
343
|
# after the first "[". Hoisted to a frozen constant so coercing a bracketed
|
|
@@ -371,35 +345,75 @@ module Phlex
|
|
|
371
345
|
BRACKET_SEGMENT = /[^\[\]]+/
|
|
372
346
|
private_constant :BRACKET_SEGMENT
|
|
373
347
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
348
|
+
class << self
|
|
349
|
+
# Public so the endpoint can expand BEFORE it peels a reactive_scope
|
|
350
|
+
# (issue #337): a JSON body keeps "todo[title]" as one literal key, which
|
|
351
|
+
# only becomes { "todo" => { "title" => … } } here. Idempotent on input
|
|
352
|
+
# that is already nested (a multipart body Rails expanded itself).
|
|
353
|
+
#
|
|
354
|
+
# Unwraps ActionController::Parameters (or a plain Hash) to a string-keyed
|
|
355
|
+
# Hash so coercion can index it uniformly, then expands bracket notation so
|
|
356
|
+
# a model-scoped Rails form's flat keys nest (issue #21). Anything else is {}.
|
|
357
|
+
def expand(value)
|
|
358
|
+
flat =
|
|
359
|
+
if value.respond_to?(:to_unsafe_h)
|
|
360
|
+
value.to_unsafe_h.stringify_keys
|
|
361
|
+
elsif value.is_a?(Hash)
|
|
362
|
+
value.stringify_keys
|
|
363
|
+
else
|
|
364
|
+
return {}
|
|
365
|
+
end
|
|
378
366
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
end
|
|
367
|
+
expand_bracket_keys(flat)
|
|
368
|
+
end
|
|
382
369
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
370
|
+
private
|
|
371
|
+
|
|
372
|
+
# The client's #collectFields keeps a form input's name verbatim, so a
|
|
373
|
+
# Rails Form(model: @invoice) posts FLAT bracketed keys like
|
|
374
|
+
# "invoice[date]". Coercion does exact-key matching, so without this a
|
|
375
|
+
# nested schema (params: { invoice: { date: … } }) never finds "invoice"
|
|
376
|
+
# and drops everything. Expand "invoice[date]" => "2026-01-02" into
|
|
377
|
+
# { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
|
|
378
|
+
# the Rails index-hash form coerce_array already understands — deep-merging
|
|
379
|
+
# so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
|
|
380
|
+
# brackets and already-nested values pass through untouched.
|
|
381
|
+
def expand_bracket_keys(flat)
|
|
382
|
+
flat.each_with_object({}) do |(key, value), out|
|
|
383
|
+
deep_assign(out, bracket_path(key), value)
|
|
384
|
+
end
|
|
392
385
|
end
|
|
393
|
-
node[leaf] = merge_value(node[leaf], value)
|
|
394
|
-
end
|
|
395
386
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
387
|
+
# "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
|
|
388
|
+
# "0", "qty"]. A key with no brackets is a single-element path.
|
|
389
|
+
def bracket_path(key)
|
|
390
|
+
return [key] unless key.include?("[")
|
|
391
|
+
|
|
392
|
+
head, rest = key.split("[", 2)
|
|
393
|
+
[head, *rest.scan(BRACKET_SEGMENT)]
|
|
394
|
+
end
|
|
401
395
|
|
|
402
|
-
|
|
396
|
+
# Walk/create nested hashes along `path`, then merge `value` at the leaf so
|
|
397
|
+
# a bracket key and a sibling pre-nested object coalesce regardless of which
|
|
398
|
+
# arrives first. #merge_value deep-merges hash/hash collisions and otherwise
|
|
399
|
+
# lets the later value win.
|
|
400
|
+
def deep_assign(hash, path, value)
|
|
401
|
+
*parents, leaf = path
|
|
402
|
+
node = parents.reduce(hash) do |acc, segment|
|
|
403
|
+
acc[segment] = {} unless acc[segment].is_a?(Hash)
|
|
404
|
+
acc[segment]
|
|
405
|
+
end
|
|
406
|
+
node[leaf] = merge_value(node[leaf], value)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Combine an existing leaf value with a new one. Two hashes deep-merge (so
|
|
410
|
+
# bracket-expanded fields and a pre-nested object for the same key both
|
|
411
|
+
# survive); any other collision takes the new value.
|
|
412
|
+
def merge_value(existing, value)
|
|
413
|
+
return value unless existing.is_a?(Hash) && value.is_a?(Hash)
|
|
414
|
+
|
|
415
|
+
existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
|
|
416
|
+
end
|
|
403
417
|
end
|
|
404
418
|
end
|
|
405
419
|
end
|