phlex-reactive 0.13.4 → 0.14.0
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 +1050 -630
- data/README.md +561 -14
- 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 +1511 -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 +85 -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 +3690 -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 +71 -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 +17 -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 +20 -2
- 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
|
@@ -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) ---
|
|
@@ -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
|
|
@@ -20,6 +20,9 @@ module Phlex
|
|
|
20
20
|
# { length: 6 } -> len_eq 6 (issue #226 — the value's
|
|
21
21
|
# { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
|
|
22
22
|
# the threshold vocabulary as len_*)
|
|
23
|
+
# { checked: 1.. } / { checked: 0 } -> checked_* (issue #319 — how many
|
|
24
|
+
# owned boxes of a checkbox GROUP are
|
|
25
|
+
# ticked; same Integer/Range shapes)
|
|
23
26
|
# Under unless: each term is NEGATED (De Morgan):
|
|
24
27
|
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
25
28
|
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
@@ -38,6 +41,14 @@ module Phlex
|
|
|
38
41
|
# — the shared fixture's emoji vector proves it.
|
|
39
42
|
LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
|
|
40
43
|
|
|
44
|
+
# The checked-count wire keys (issue #319) — the client mirrors them in
|
|
45
|
+
# SHOW_CHECKED_KEYS. They count the TICKED boxes of a checkbox group (an
|
|
46
|
+
# Array value counts its entries; an Integer is the count itself).
|
|
47
|
+
CHECKED_KEYS = %w[checked_eq checked_gte checked_gt checked_lte checked_lt].freeze
|
|
48
|
+
|
|
49
|
+
# A Hash value's one key -> its wire-key prefix.
|
|
50
|
+
COUNT_PREFIXES = { "length" => "len", "checked" => "checked" }.freeze
|
|
51
|
+
|
|
41
52
|
module_function
|
|
42
53
|
|
|
43
54
|
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
@@ -136,7 +147,7 @@ module Phlex
|
|
|
136
147
|
case value
|
|
137
148
|
when Range then range_terms(name, value)
|
|
138
149
|
when Array then [membership_term(name, value)]
|
|
139
|
-
when Hash then
|
|
150
|
+
when Hash then count_terms(name, value)
|
|
140
151
|
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
141
152
|
end
|
|
142
153
|
end
|
|
@@ -180,90 +191,95 @@ module Phlex
|
|
|
180
191
|
end
|
|
181
192
|
end
|
|
182
193
|
|
|
183
|
-
# --- the length
|
|
194
|
+
# --- the count predicates: length: (#226) and checked: (#319) ---------
|
|
184
195
|
|
|
185
|
-
# A Hash value names a STRUCTURAL predicate
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
def
|
|
191
|
-
value =
|
|
196
|
+
# A Hash value names a STRUCTURAL predicate over a COUNT — length: (the
|
|
197
|
+
# value's codepoints) or checked: (the ticked boxes of a checkbox group).
|
|
198
|
+
# Exact Integer -> one *_eq term; an Integer Range -> *_gte/*_lte/*_lt
|
|
199
|
+
# terms exactly like range_terms. A count is total (blank/absent -> 0),
|
|
200
|
+
# so { length: 0 } / { checked: 0 } legitimately match a blank field.
|
|
201
|
+
def count_terms(name, hash)
|
|
202
|
+
kind, value = count_value!(name, hash)
|
|
203
|
+
prefix = COUNT_PREFIXES.fetch(kind)
|
|
192
204
|
case value
|
|
193
205
|
when Integer
|
|
194
|
-
|
|
195
|
-
[{ "field" => name, "
|
|
196
|
-
when Range then
|
|
197
|
-
else
|
|
206
|
+
validate_count_literal!(name, kind, value)
|
|
207
|
+
[{ "field" => name, "#{prefix}_eq" => value }]
|
|
208
|
+
when Range then count_range_terms(name, kind, value)
|
|
209
|
+
else raise_count_kind(name, kind, value)
|
|
198
210
|
end
|
|
199
211
|
end
|
|
200
212
|
|
|
201
|
-
def
|
|
213
|
+
def count_range_terms(name, kind, range)
|
|
214
|
+
prefix = COUNT_PREFIXES.fetch(kind)
|
|
202
215
|
first = range.begin
|
|
203
216
|
last = range.end
|
|
204
|
-
|
|
205
|
-
[first, last].compact.each {
|
|
217
|
+
raise_count_kind(name, kind, range) if first.nil? && last.nil?
|
|
218
|
+
[first, last].compact.each { validate_count_literal!(name, kind, it) }
|
|
206
219
|
|
|
207
220
|
terms = []
|
|
208
|
-
terms << { "field" => name, "
|
|
221
|
+
terms << { "field" => name, "#{prefix}_gte" => first } unless first.nil?
|
|
209
222
|
if last
|
|
210
|
-
terms << { "field" => name,
|
|
223
|
+
terms << { "field" => name, "#{prefix}_#{range.exclude_end? ? "lt" : "lte"}" => last }
|
|
211
224
|
end
|
|
212
225
|
terms
|
|
213
226
|
end
|
|
214
227
|
|
|
215
|
-
# The complement of a
|
|
216
|
-
# negates to the outside disjunction (
|
|
228
|
+
# The complement of a count predicate, under unless:. An exact count
|
|
229
|
+
# negates to the outside disjunction (n < x OR n > x) — two
|
|
217
230
|
# alternatives, like a bounded numeric range; ranges complement each leg
|
|
218
|
-
# (not-gte -> lt, not-lte -> gt, not-lt -> gte).
|
|
231
|
+
# (not-gte -> lt, not-lte -> gt, not-lt -> gte). A count is total, so
|
|
219
232
|
# unlike the numeric complements there is no blank/NaN fail-closed
|
|
220
233
|
# asymmetry — the complement is exact.
|
|
221
|
-
def
|
|
222
|
-
value =
|
|
234
|
+
def count_complement(name, hash)
|
|
235
|
+
kind, value = count_value!(name, hash)
|
|
236
|
+
prefix = COUNT_PREFIXES.fetch(kind)
|
|
223
237
|
case value
|
|
224
238
|
when Integer
|
|
225
|
-
|
|
226
|
-
[[{ "field" => name, "
|
|
239
|
+
validate_count_literal!(name, kind, value)
|
|
240
|
+
[[{ "field" => name, "#{prefix}_lt" => value }], [{ "field" => name, "#{prefix}_gt" => value }]]
|
|
227
241
|
when Range
|
|
228
242
|
first = value.begin
|
|
229
243
|
last = value.end
|
|
230
|
-
|
|
231
|
-
[first, last].compact.each {
|
|
244
|
+
raise_count_kind(name, kind, value) if first.nil? && last.nil?
|
|
245
|
+
[first, last].compact.each { validate_count_literal!(name, kind, it) }
|
|
232
246
|
|
|
233
|
-
low = first.nil? ? nil : [{ "field" => name, "
|
|
247
|
+
low = first.nil? ? nil : [{ "field" => name, "#{prefix}_lt" => first }]
|
|
234
248
|
high =
|
|
235
249
|
if last.nil? then nil
|
|
236
|
-
elsif value.exclude_end? then [{ "field" => name, "
|
|
237
|
-
else [{ "field" => name, "
|
|
250
|
+
elsif value.exclude_end? then [{ "field" => name, "#{prefix}_gte" => last }]
|
|
251
|
+
else [{ "field" => name, "#{prefix}_gt" => last }]
|
|
238
252
|
end
|
|
239
253
|
[low, high].compact
|
|
240
|
-
else
|
|
254
|
+
else raise_count_kind(name, kind, value)
|
|
241
255
|
end
|
|
242
256
|
end
|
|
243
257
|
|
|
244
|
-
# The one Hash key must be length: — anything else is a
|
|
245
|
-
# that must fail at render, never silently in the
|
|
246
|
-
|
|
247
|
-
|
|
258
|
+
# The one Hash key must be length: or checked: — anything else is a
|
|
259
|
+
# typo'd predicate that must fail at render, never silently in the
|
|
260
|
+
# browser. Returns [kind, value].
|
|
261
|
+
def count_value!(name, hash)
|
|
262
|
+
kind = hash.keys.first.to_s if hash.size == 1
|
|
263
|
+
unless COUNT_PREFIXES.key?(kind)
|
|
248
264
|
raise ArgumentError,
|
|
249
|
-
"reactive_show: #{name.inspect} Hash value supports only length: " \
|
|
265
|
+
"reactive_show: #{name.inspect} Hash value supports only length: or checked: " \
|
|
250
266
|
"(got #{hash.keys.inspect})"
|
|
251
267
|
end
|
|
252
268
|
|
|
253
|
-
hash.values.first
|
|
269
|
+
[kind, hash.values.first]
|
|
254
270
|
end
|
|
255
271
|
|
|
256
|
-
def
|
|
272
|
+
def validate_count_literal!(name, kind, value)
|
|
257
273
|
return if value.is_a?(Integer) && value >= 0
|
|
258
274
|
|
|
259
275
|
raise ArgumentError,
|
|
260
|
-
"reactive_show: #{name.inspect}
|
|
261
|
-
"(a codepoint count), got #{value.inspect}"
|
|
276
|
+
"reactive_show: #{name.inspect} #{kind}: takes a non-negative Integer " \
|
|
277
|
+
"(a #{kind == "length" ? "codepoint" : "ticked-box"} count), got #{value.inspect}"
|
|
262
278
|
end
|
|
263
279
|
|
|
264
|
-
def
|
|
280
|
+
def raise_count_kind(name, kind, value)
|
|
265
281
|
raise ArgumentError,
|
|
266
|
-
"reactive_show: #{name.inspect}
|
|
282
|
+
"reactive_show: #{name.inspect} #{kind}: takes a non-negative Integer " \
|
|
267
283
|
"or an Integer Range, got #{value.inspect}"
|
|
268
284
|
end
|
|
269
285
|
|
|
@@ -278,7 +294,7 @@ module Phlex
|
|
|
278
294
|
name = field.to_s
|
|
279
295
|
case value
|
|
280
296
|
when Range then range_complement(name, value)
|
|
281
|
-
when Hash then
|
|
297
|
+
when Hash then count_complement(name, value)
|
|
282
298
|
when Array
|
|
283
299
|
list = value.map(&:to_s)
|
|
284
300
|
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
@@ -320,28 +336,40 @@ module Phlex
|
|
|
320
336
|
if term.key?("equals") then value == term["equals"]
|
|
321
337
|
elsif term.key?("not") then value != term["not"]
|
|
322
338
|
elsif term.key?("in") then term["in"].include?(value)
|
|
323
|
-
elsif (key = LENGTH_KEYS.find { term.key?(it) }) then
|
|
339
|
+
elsif (key = LENGTH_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], value.to_s.length)
|
|
340
|
+
elsif (key = CHECKED_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], checked_count(value))
|
|
324
341
|
else numeric_term_matches?(term, value)
|
|
325
342
|
end
|
|
326
343
|
end
|
|
327
344
|
|
|
328
|
-
# len_* — compare the value's
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
|
|
345
|
+
# len_* / checked_* — compare a count (the value's CODEPOINTS via
|
|
346
|
+
# String#length, or the group's ticked boxes) against the Integer literal.
|
|
347
|
+
# A count is total (blank/absent -> 0), so every value is decidable; a
|
|
348
|
+
# malformed non-Integer literal (a hand-built term) is fail-closed,
|
|
349
|
+
# mirroring the client's warn-skip.
|
|
350
|
+
def count_matches?(key, literal, count)
|
|
333
351
|
return false unless literal.is_a?(Integer)
|
|
334
352
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
when "
|
|
338
|
-
when "
|
|
339
|
-
when "
|
|
340
|
-
when "
|
|
341
|
-
when "len_lt" then length < literal
|
|
353
|
+
case key.delete_prefix("len_").delete_prefix("checked_")
|
|
354
|
+
when "eq" then count == literal
|
|
355
|
+
when "gte" then count >= literal
|
|
356
|
+
when "gt" then count > literal
|
|
357
|
+
when "lte" then count <= literal
|
|
358
|
+
when "lt" then count < literal
|
|
342
359
|
end
|
|
343
360
|
end
|
|
344
361
|
|
|
362
|
+
# How many boxes of a group are ticked, from the first-paint value: an
|
|
363
|
+
# Array (the checked values) counts its entries, an Integer (or its
|
|
364
|
+
# string) is the count, a lone checkbox's "true"/"false" is 1/0, and a
|
|
365
|
+
# blank/absent group is 0 — the client counts the owned ticked boxes.
|
|
366
|
+
def checked_count(value)
|
|
367
|
+
return value.size if value.is_a?(Array)
|
|
368
|
+
return 1 if value.to_s == "true"
|
|
369
|
+
|
|
370
|
+
Integer(value.to_s, exception: false) || 0
|
|
371
|
+
end
|
|
372
|
+
|
|
345
373
|
# gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
|
|
346
374
|
# value is fail-closed (never matches). Mirrors the client's
|
|
347
375
|
# numericPredicateMatches (blank/whitespace -> NaN -> false).
|
|
@@ -75,7 +75,7 @@ module Phlex
|
|
|
75
75
|
broadcast_remove_to_each: "broadcast_to(each: keys, remove: model)"
|
|
76
76
|
}.freeze
|
|
77
77
|
|
|
78
|
-
# Every class that includes Streamable, so the engine can flush their
|
|
78
|
+
# Every class that includes Streamable or subclasses one, so the engine can flush their
|
|
79
79
|
# memoized view contexts on a Rails code reload (dev) in one pass. A
|
|
80
80
|
# WeakMap used as a set (the class is the key) so a class reloaded by
|
|
81
81
|
# Zeitwerk in dev is GC'd normally — a strong Array would pin every
|
|
@@ -148,7 +148,15 @@ module Phlex
|
|
|
148
148
|
# different hosts — absolute URLs in broadcast-rendered components
|
|
149
149
|
# keep the process defaults, so "host-relative URLs" stays the
|
|
150
150
|
# broadcast contract. Off-request callers pay one nil-write pair.
|
|
151
|
-
|
|
151
|
+
# Nor the actor's AWAKE dormant roots (issue #274): a subscriber may
|
|
152
|
+
# never have loaded the client, so its copy stays dormant (one
|
|
153
|
+
# fiber-local read off-request).
|
|
154
|
+
html =
|
|
155
|
+
if verb != :js
|
|
156
|
+
Phlex::Reactive::Dormant.asleep do
|
|
157
|
+
Phlex::Reactive.with_url_options(nil) { render_broadcast_html(component) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
152
160
|
ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
|
|
153
161
|
keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
|
|
154
162
|
end
|
|
@@ -313,6 +321,16 @@ module Phlex
|
|
|
313
321
|
end
|
|
314
322
|
|
|
315
323
|
class_methods do
|
|
324
|
+
# A subclass inherits Streamable rather than including it, so `included`
|
|
325
|
+
# never fires for it (issue #302). Register it here so the inventory
|
|
326
|
+
# (rake tasks, MCP tools), the doctor and the reload reset see it too.
|
|
327
|
+
# Anonymous subclasses register as well; readers filter to
|
|
328
|
+
# constant-resolvable classes, and the WeakMap lets them be GC'd.
|
|
329
|
+
def inherited(subclass)
|
|
330
|
+
super
|
|
331
|
+
Phlex::Reactive::Streamable.register(subclass)
|
|
332
|
+
end
|
|
333
|
+
|
|
316
334
|
# The keyword the positional model maps to in `initialize`. For a
|
|
317
335
|
# record-backed component (Component#reactive_record), this is the SAME
|
|
318
336
|
# keyword the action endpoint uses in `from_identity` — so one
|