phlex-reactive 0.12.2 → 0.12.4
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 +80 -0
- data/README.md +163 -6
- data/app/javascript/phlex/reactive/compute.js +158 -0
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +319 -5
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/dsl.rb +84 -0
- data/lib/phlex/reactive/component/helpers.rb +21 -0
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +9 -0
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/show_conditions.rb +116 -0
- data/lib/phlex/reactive/streamable.rb +35 -23
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -450,6 +450,71 @@ module Phlex
|
|
|
450
450
|
Registry.resolve_hash(self, :computes, :reactive_computes)
|
|
451
451
|
end
|
|
452
452
|
|
|
453
|
+
# A fresh client-op chain at CLASS level (issue #226), so a
|
|
454
|
+
# reactive_on_complete declaration reads like its on_client sibling:
|
|
455
|
+
#
|
|
456
|
+
# reactive_on_complete if: { code: { length: 6 } },
|
|
457
|
+
# run: js.dispatch("code:complete")
|
|
458
|
+
#
|
|
459
|
+
# The instance helper (Component::Helpers#js) is unchanged.
|
|
460
|
+
def js
|
|
461
|
+
Phlex::Reactive::JS.new
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Declarative completion binding (issue #226): when the declared
|
|
465
|
+
# conditions FIRST become true — evaluated client-side over the owned
|
|
466
|
+
# fields on input/change — run a chain of client DOM ops. The
|
|
467
|
+
# conditions are the SAME if:/if_any:/unless: kwargs reactive_show
|
|
468
|
+
# takes (the ONE ShowConditions language, including the length: form);
|
|
469
|
+
# `run:` is a js chain or a raw [[op, args], …] list (allowlist
|
|
470
|
+
# re-checked, like every raw-ops escape hatch).
|
|
471
|
+
#
|
|
472
|
+
# reactive_on_complete if: { code: { length: 6 } },
|
|
473
|
+
# run: js.dispatch("code:complete")
|
|
474
|
+
# reactive_on_complete :commit, if: { code: { length: 6 } }, run: js.submit
|
|
475
|
+
#
|
|
476
|
+
# RISING-EDGE, actor-gesture semantics on the client: ops fire once on
|
|
477
|
+
# the flip to true, re-arm when the conditions go false, and the
|
|
478
|
+
# connect/morph pass arms WITHOUT firing (a re-render with already-
|
|
479
|
+
# satisfied conditions never self-fires). The optional NAME (default
|
|
480
|
+
# :default) keys the registry so a component can declare several
|
|
481
|
+
# independent bindings; redeclaring a name overrides it (inheritance
|
|
482
|
+
# follows the Registry rules, like reactive_compute).
|
|
483
|
+
def reactive_on_complete(name = :default, run:, **conditions)
|
|
484
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
485
|
+
Registry.write_entry(
|
|
486
|
+
self, :on_completes, name.to_sym,
|
|
487
|
+
OnCompleteDefinition.new(
|
|
488
|
+
name: name.to_sym, conditions: groups,
|
|
489
|
+
ops: normalize_on_complete_ops(name, run)
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def reactive_on_completes
|
|
495
|
+
Registry.resolve_hash(self, :on_completes, :reactive_on_completes)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The wire JSON for the root's data-reactive-on-complete attr — a
|
|
499
|
+
# frozen array of { any:, ops: } bindings, or nil when undeclared
|
|
500
|
+
# (byte-stable wire). Memoized per class against the registry
|
|
501
|
+
# generation (one integer compare) because reactive_attrs is the
|
|
502
|
+
# token-signing hot path — the reactive_effect_attrs precedent.
|
|
503
|
+
def reactive_on_complete_attr
|
|
504
|
+
registry_gen = Registry.generation
|
|
505
|
+
return @reactive_on_complete_attr if @reactive_on_complete_attr_gen == registry_gen
|
|
506
|
+
|
|
507
|
+
bindings = reactive_on_completes
|
|
508
|
+
@reactive_on_complete_attr =
|
|
509
|
+
if bindings.empty?
|
|
510
|
+
nil
|
|
511
|
+
else
|
|
512
|
+
bindings.values.map { { "any" => it.conditions, "ops" => it.ops } }.to_json.freeze
|
|
513
|
+
end
|
|
514
|
+
@reactive_on_complete_attr_gen = registry_gen
|
|
515
|
+
@reactive_on_complete_attr
|
|
516
|
+
end
|
|
517
|
+
|
|
453
518
|
# Fetch one compute definition — the reader form matching
|
|
454
519
|
# reactive_collection_def (issue #115). The bare reactive_compute(name)
|
|
455
520
|
# getter above is a PERMANENT documented alias (it shipped in the same
|
|
@@ -480,6 +545,25 @@ module Phlex
|
|
|
480
545
|
# nil types, so the array-form wire stays byte-identical and the client
|
|
481
546
|
# keeps its numeric coercion.
|
|
482
547
|
#
|
|
548
|
+
# Validate + normalize a reactive_on_complete run: chain (issue #226)
|
|
549
|
+
# to the raw [[op, args], …] pairs. A js chain is already validated at
|
|
550
|
+
# build time; a raw list re-runs the attr allowlist — the same
|
|
551
|
+
# defense-in-depth every raw-ops escape hatch gets (js([...]) /
|
|
552
|
+
# broadcast_to(js: [...])). Loud on anything else — a dead or hostile
|
|
553
|
+
# binding must fail at class load, never no-op in the browser.
|
|
554
|
+
def normalize_on_complete_ops(name, run)
|
|
555
|
+
pairs = run.is_a?(Phlex::Reactive::JS) ? run.ops : run
|
|
556
|
+
unless pairs.is_a?(Array)
|
|
557
|
+
raise ArgumentError,
|
|
558
|
+
"reactive_on_complete #{name.inspect} run: takes a js chain " \
|
|
559
|
+
"(js.dispatch(\"code:complete\")) or a raw [[op, args], ...] list, got #{run.class}"
|
|
560
|
+
end
|
|
561
|
+
raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
|
|
562
|
+
|
|
563
|
+
Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
|
|
564
|
+
pairs
|
|
565
|
+
end
|
|
566
|
+
|
|
483
567
|
# Named after the shape it normalizes.
|
|
484
568
|
def normalize_compute_inputs(inputs)
|
|
485
569
|
return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
|
|
@@ -98,6 +98,14 @@ module Phlex
|
|
|
98
98
|
if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
|
|
99
99
|
data.merge!(effects)
|
|
100
100
|
end
|
|
101
|
+
# Completion bindings (issue #226): the declared reactive_on_complete
|
|
102
|
+
# bindings ride the root as ONE JSON attr the client evaluates over
|
|
103
|
+
# the owned fields (rising edge → run the ops). nil when undeclared —
|
|
104
|
+
# no key, byte-stable wire. Per-class memo (one integer compare).
|
|
105
|
+
if self.class.respond_to?(:reactive_on_complete_attr) &&
|
|
106
|
+
(on_complete = self.class.reactive_on_complete_attr)
|
|
107
|
+
data[:reactive_on_complete] = on_complete
|
|
108
|
+
end
|
|
101
109
|
{ data: }
|
|
102
110
|
end
|
|
103
111
|
|
|
@@ -381,6 +389,14 @@ module Phlex
|
|
|
381
389
|
raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
|
|
382
390
|
|
|
383
391
|
event = event.to_s
|
|
392
|
+
# Issue #226: requestSubmit dispatches the very `submit` event this
|
|
393
|
+
# trigger would be bound to — an infinite loop. Loud at render.
|
|
394
|
+
if event == "submit" && ops.ops.any? { |name, _| name == "submit" }
|
|
395
|
+
raise ArgumentError,
|
|
396
|
+
"on_client(:submit, js.submit) would re-fire itself — requestSubmit dispatches the " \
|
|
397
|
+
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
398
|
+
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
399
|
+
end
|
|
384
400
|
window_bound = window || outside
|
|
385
401
|
attrs = {
|
|
386
402
|
data: {
|
|
@@ -392,6 +408,11 @@ module Phlex
|
|
|
392
408
|
# on()'s flags above.
|
|
393
409
|
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
394
410
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
411
|
+
# Issue #228: mark a clipboard-reading trigger so the client can gate
|
|
412
|
+
# its visibility on availability — on connect the controller sets
|
|
413
|
+
# hidden = !navigator.clipboard.readText. Render the trigger `hidden`
|
|
414
|
+
# for reveal-when-available (a dead paste button never shows).
|
|
415
|
+
attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
|
|
395
416
|
# Issue #178: confirm: gates the client-op chain behind the SAME
|
|
396
417
|
# overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
|
|
397
418
|
# the identical data-reactive-confirm-param; the client's runOps prompts
|
|
@@ -50,6 +50,8 @@ module Phlex
|
|
|
50
50
|
state_keys: :@reactive_own_state_keys,
|
|
51
51
|
collections: :@reactive_own_collections,
|
|
52
52
|
computes: :@reactive_own_computes,
|
|
53
|
+
# Completion bindings — reactive_on_complete (issue #226).
|
|
54
|
+
on_completes: :@reactive_own_on_completes,
|
|
53
55
|
record_key: :@reactive_own_record_key,
|
|
54
56
|
# Form-field namespace — reactive_scope (issue #180).
|
|
55
57
|
scope: :@reactive_own_scope,
|
|
@@ -104,6 +104,15 @@ module Phlex
|
|
|
104
104
|
# default-deny; the client interpreter re-checks the shape).
|
|
105
105
|
ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types, :mirror)
|
|
106
106
|
|
|
107
|
+
# A declared completion binding (issue #226): `conditions` are the
|
|
108
|
+
# pre-compiled ShowConditions DNF groups (the ONE conditions language,
|
|
109
|
+
# including the #226 length: form); `ops` is the validated [[op, args],…]
|
|
110
|
+
# client-op chain. The generic controller evaluates the conditions over
|
|
111
|
+
# the owned fields on input/change and runs the ops on the RISING EDGE —
|
|
112
|
+
# the conditions' first flip to true — through the same frozen CLIENT_OPS
|
|
113
|
+
# whitelist as on_client. The connect/morph pass arms without firing.
|
|
114
|
+
OnCompleteDefinition = Data.define(:name, :conditions, :ops)
|
|
115
|
+
|
|
107
116
|
# A declared add/remove-row collection (issue #35): the list contract tied
|
|
108
117
|
# into one unit — the per-row item component, the container DOM id rows
|
|
109
118
|
# live in, an optional companion count id, an optional empty-state
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -170,6 +170,50 @@ module Phlex
|
|
|
170
170
|
append("focus_first", target_args(to, global:))
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# --- Submit (issue #226) ---
|
|
174
|
+
#
|
|
175
|
+
# submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
|
|
176
|
+
# itself when it IS a form, else its form owner (input.form, honoring a
|
|
177
|
+
# form= attribute), else the nearest ancestor form. requestSubmit runs
|
|
178
|
+
# constraint validation and fires a REAL cancelable `submit` event, so it
|
|
179
|
+
# composes with both a native/Turbo form and an on(:action, event:
|
|
180
|
+
# "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
|
|
181
|
+
# reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
|
|
182
|
+
# submit would force-submit every subscriber's form.
|
|
183
|
+
|
|
184
|
+
def submit(to = :root, global: false)
|
|
185
|
+
append("submit", target_args(to, global:))
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# --- Clipboard-source paste (issue #228) ---
|
|
189
|
+
#
|
|
190
|
+
# paste_into(to) — on a user gesture, read navigator.clipboard.readText()
|
|
191
|
+
# and feed the text into the target field through the normal `input`
|
|
192
|
+
# pipeline: set .value, dispatch a bubbling `input` event (so
|
|
193
|
+
# reactive_compute reducers, reactive_show, and reactive_on_complete all
|
|
194
|
+
# run exactly as if the user had typed it), then focus the field. The
|
|
195
|
+
# permission UX is the browser's own; a rejected or unavailable read is a
|
|
196
|
+
# SILENT NO-OP — page state must not change. The target is a FIELD: there
|
|
197
|
+
# is no :root default and an explicit :root is refused loudly (pasting
|
|
198
|
+
# into the root div is always a call-site bug). ACTOR-ONLY like
|
|
199
|
+
# focus/submit: allowed from on_client / reply.js / reactive_on_complete,
|
|
200
|
+
# refused in broadcast_to(js:) — a broadcast that reads every
|
|
201
|
+
# subscriber's clipboard would be hostile. on_client marks the trigger
|
|
202
|
+
# with data-reactive-clipboard so the client can hide it wherever the
|
|
203
|
+
# clipboard API is missing (no dead button). NOTE: the gate owns the
|
|
204
|
+
# trigger's `hidden` flag — don't also bind reactive_show to the trigger
|
|
205
|
+
# element (the two passes would fight over the same attribute).
|
|
206
|
+
|
|
207
|
+
def paste_into(to, global: false)
|
|
208
|
+
if to == :root
|
|
209
|
+
raise ArgumentError,
|
|
210
|
+
"#{self.class}: paste_into targets a field, not the component root — " \
|
|
211
|
+
"pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
append("paste_into", target_args(to, global:))
|
|
215
|
+
end
|
|
216
|
+
|
|
173
217
|
# --- Text content (issue #159) ---
|
|
174
218
|
#
|
|
175
219
|
# text(to, value) — set the target's textContent (stringified; nil clears).
|
|
@@ -17,6 +17,9 @@ module Phlex
|
|
|
17
17
|
# (..10) -> lte 10
|
|
18
18
|
# (...10) -> lt 10
|
|
19
19
|
# (10..20) -> gte 10 AND lte 20 (two terms, one group)
|
|
20
|
+
# { length: 6 } -> len_eq 6 (issue #226 — the value's
|
|
21
|
+
# { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
|
|
22
|
+
# the threshold vocabulary as len_*)
|
|
20
23
|
# Under unless: each term is NEGATED (De Morgan):
|
|
21
24
|
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
22
25
|
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
@@ -29,6 +32,12 @@ module Phlex
|
|
|
29
32
|
# bounded-range complement splits). There is NO expression surface: every
|
|
30
33
|
# term is a declared literal predicate — the pre-#180 default-deny posture.
|
|
31
34
|
module ShowConditions
|
|
35
|
+
# The length-predicate wire keys (issue #226) — the Ruby half; the client
|
|
36
|
+
# mirrors them in SHOW_LENGTH_KEYS. Length is counted in CODEPOINTS
|
|
37
|
+
# (String#length here, [...value].length there) so multibyte values agree
|
|
38
|
+
# — the shared fixture's emoji vector proves it.
|
|
39
|
+
LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
|
|
40
|
+
|
|
32
41
|
module_function
|
|
33
42
|
|
|
34
43
|
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
@@ -128,6 +137,7 @@ module Phlex
|
|
|
128
137
|
case value
|
|
129
138
|
when Range then range_terms(name, value)
|
|
130
139
|
when Array then [membership_term(name, value)]
|
|
140
|
+
when Hash then length_terms(name, value)
|
|
131
141
|
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
132
142
|
end
|
|
133
143
|
end
|
|
@@ -171,6 +181,93 @@ module Phlex
|
|
|
171
181
|
end
|
|
172
182
|
end
|
|
173
183
|
|
|
184
|
+
# --- the length predicate (issue #226) ----------------------------------
|
|
185
|
+
|
|
186
|
+
# A Hash value names a STRUCTURAL predicate — today only length:. Exact
|
|
187
|
+
# Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt
|
|
188
|
+
# terms exactly like range_terms. Length is a total function over the
|
|
189
|
+
# value's codepoints (blank/absent -> 0), so { length: 0 } legitimately
|
|
190
|
+
# matches a blank field.
|
|
191
|
+
def length_terms(name, hash)
|
|
192
|
+
value = length_value!(name, hash)
|
|
193
|
+
case value
|
|
194
|
+
when Integer
|
|
195
|
+
validate_length_literal!(name, value)
|
|
196
|
+
[{ "field" => name, "len_eq" => value }]
|
|
197
|
+
when Range then length_range_terms(name, value)
|
|
198
|
+
else raise_length_kind(name, value)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def length_range_terms(name, range)
|
|
203
|
+
first = range.begin
|
|
204
|
+
last = range.end
|
|
205
|
+
raise_length_kind(name, range) if first.nil? && last.nil?
|
|
206
|
+
[first, last].compact.each { validate_length_literal!(name, it) }
|
|
207
|
+
|
|
208
|
+
terms = []
|
|
209
|
+
terms << { "field" => name, "len_gte" => first } unless first.nil?
|
|
210
|
+
if last
|
|
211
|
+
terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last }
|
|
212
|
+
end
|
|
213
|
+
terms
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The complement of a length predicate, under unless:. Exact length
|
|
217
|
+
# negates to the outside disjunction (len < n OR len > n) — two
|
|
218
|
+
# alternatives, like a bounded numeric range; ranges complement each leg
|
|
219
|
+
# (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so
|
|
220
|
+
# unlike the numeric complements there is no blank/NaN fail-closed
|
|
221
|
+
# asymmetry — the complement is exact.
|
|
222
|
+
def length_complement(name, hash)
|
|
223
|
+
value = length_value!(name, hash)
|
|
224
|
+
case value
|
|
225
|
+
when Integer
|
|
226
|
+
validate_length_literal!(name, value)
|
|
227
|
+
[[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]]
|
|
228
|
+
when Range
|
|
229
|
+
first = value.begin
|
|
230
|
+
last = value.end
|
|
231
|
+
raise_length_kind(name, value) if first.nil? && last.nil?
|
|
232
|
+
[first, last].compact.each { validate_length_literal!(name, it) }
|
|
233
|
+
|
|
234
|
+
low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }]
|
|
235
|
+
high =
|
|
236
|
+
if last.nil? then nil
|
|
237
|
+
elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }]
|
|
238
|
+
else [{ "field" => name, "len_gt" => last }]
|
|
239
|
+
end
|
|
240
|
+
[low, high].compact
|
|
241
|
+
else raise_length_kind(name, value)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The one Hash key must be length: — anything else is a typo'd predicate
|
|
246
|
+
# that must fail at render, never silently in the browser.
|
|
247
|
+
def length_value!(name, hash)
|
|
248
|
+
unless hash.size == 1 && hash.keys.first.to_s == "length"
|
|
249
|
+
raise ArgumentError,
|
|
250
|
+
"reactive_show: #{name.inspect} Hash value supports only length: " \
|
|
251
|
+
"(got #{hash.keys.inspect})"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
hash.values.first
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_length_literal!(name, value)
|
|
258
|
+
return if value.is_a?(Integer) && value >= 0
|
|
259
|
+
|
|
260
|
+
raise ArgumentError,
|
|
261
|
+
"reactive_show: #{name.inspect} length: takes a non-negative Integer " \
|
|
262
|
+
"(a codepoint count), got #{value.inspect}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def raise_length_kind(name, value)
|
|
266
|
+
raise ArgumentError,
|
|
267
|
+
"reactive_show: #{name.inspect} length: takes a non-negative Integer " \
|
|
268
|
+
"or an Integer Range, got #{value.inspect}"
|
|
269
|
+
end
|
|
270
|
+
|
|
174
271
|
# --- the value language (negated, under unless:) -----------------------
|
|
175
272
|
|
|
176
273
|
# A field => value under unless: -> a LIST of alternative term-lists.
|
|
@@ -182,6 +279,7 @@ module Phlex
|
|
|
182
279
|
name = field.to_s
|
|
183
280
|
case value
|
|
184
281
|
when Range then range_complement(name, value)
|
|
282
|
+
when Hash then length_complement(name, value)
|
|
185
283
|
when Array
|
|
186
284
|
list = value.map(&:to_s)
|
|
187
285
|
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
@@ -223,10 +321,28 @@ module Phlex
|
|
|
223
321
|
if term.key?("equals") then value == term["equals"]
|
|
224
322
|
elsif term.key?("not") then value != term["not"]
|
|
225
323
|
elsif term.key?("in") then term["in"].include?(value)
|
|
324
|
+
elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value)
|
|
226
325
|
else numeric_term_matches?(term, value)
|
|
227
326
|
end
|
|
228
327
|
end
|
|
229
328
|
|
|
329
|
+
# len_* — compare the value's CODEPOINT count (String#length) against the
|
|
330
|
+
# Integer literal. Length is total (blank/absent -> 0), so every value is
|
|
331
|
+
# decidable; a malformed non-Integer literal (a hand-built term) is
|
|
332
|
+
# fail-closed, mirroring the client's warn-skip.
|
|
333
|
+
def length_term_matches?(key, literal, value)
|
|
334
|
+
return false unless literal.is_a?(Integer)
|
|
335
|
+
|
|
336
|
+
length = value.to_s.length
|
|
337
|
+
case key
|
|
338
|
+
when "len_eq" then length == literal
|
|
339
|
+
when "len_gte" then length >= literal
|
|
340
|
+
when "len_gt" then length > literal
|
|
341
|
+
when "len_lte" then length <= literal
|
|
342
|
+
when "len_lt" then length < literal
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
230
346
|
# gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
|
|
231
347
|
# value is fail-closed (never matches). Mirrors the client's
|
|
232
348
|
# numericPredicateMatches (blank/whitespace -> NaN -> false).
|
|
@@ -33,9 +33,13 @@ module Phlex
|
|
|
33
33
|
# up without ever sharing a mutable context across threads.
|
|
34
34
|
ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
36
|
+
# Actor-only ops: a js broadcast rejects a broadcast that carries one.
|
|
37
|
+
# Focus ops steal focus in every subscriber's tab (issue #96); submit
|
|
38
|
+
# (issue #226) would force-submit every subscriber's form; paste_into
|
|
39
|
+
# (issue #228) would read every subscriber's clipboard. They belong to
|
|
40
|
+
# the actor's own reply (reply.js) or gesture (on_client / a reducer's
|
|
41
|
+
# $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
|
|
42
|
+
BROADCAST_REFUSED_OPS = %w[focus focus_first submit paste_into].freeze
|
|
39
43
|
|
|
40
44
|
# The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
|
|
41
45
|
# SELF-TARGETING verbs derive the target from the component's #id (require a
|
|
@@ -121,22 +125,45 @@ module Phlex
|
|
|
121
125
|
"append/prepend/remove), not client-op dispatches"
|
|
122
126
|
end
|
|
123
127
|
resolved_target = resolve_broadcast_target(verb, component, payload, target)
|
|
124
|
-
#
|
|
125
|
-
# class
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
+
# The instrumentation + pgbus thread-locals are owned HERE so the
|
|
129
|
+
# class-level and module-level (plain-component) forms share ONE path
|
|
130
|
+
# and neither is silently un-instrumented (issue #185). The js ops
|
|
131
|
+
# serializer lives HERE too (issue #228): when it was a private method
|
|
132
|
+
# on the includer class, the module-level owner (the Streamable module
|
|
133
|
+
# itself) crashed with NoMethodError instead of refusing — the
|
|
134
|
+
# actor-only gate must be reachable from BOTH broadcast doors.
|
|
128
135
|
component_name = component ? component.class.name : owner.name
|
|
129
136
|
Phlex::Reactive.instrument(
|
|
130
137
|
"broadcast", { component: component_name, stream_action: BROADCAST_VERBS[verb], streamables: keys.size }
|
|
131
138
|
) do
|
|
132
139
|
with_pgbus_broadcast_opts(exclude:, visible_to:) do
|
|
133
140
|
html = verb == :js ? nil : render_broadcast_html(component)
|
|
134
|
-
ops_json = verb == :js ?
|
|
141
|
+
ops_json = verb == :js ? broadcast_js_ops_json(payload) : nil
|
|
135
142
|
keys.each { dispatch_broadcast(verb, it, resolved_target, html, ops_json, morph, effect) }
|
|
136
143
|
end
|
|
137
144
|
end
|
|
138
145
|
end
|
|
139
146
|
|
|
147
|
+
# Validate + serialize broadcast ops: reject actor-only ops (focus steals
|
|
148
|
+
# focus in every tab; submit force-submits every subscriber's form;
|
|
149
|
+
# paste_into reads every subscriber's clipboard) and an empty chain (a
|
|
150
|
+
# dead broadcast), then return the JSON wire form. Works on a JS chain
|
|
151
|
+
# (inspect .ops) and a raw array. Lives on the module singleton — the
|
|
152
|
+
# ONE enforcement point both broadcast doors funnel through.
|
|
153
|
+
def broadcast_js_ops_json(ops)
|
|
154
|
+
pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
|
|
155
|
+
refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
|
|
156
|
+
unless refused.empty?
|
|
157
|
+
raise ArgumentError,
|
|
158
|
+
"broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
|
|
159
|
+
"steals it in every subscriber's tab; broadcasting submit force-submits every " \
|
|
160
|
+
"subscriber's form; broadcasting paste_into reads every subscriber's clipboard. " \
|
|
161
|
+
"These are actor concerns (reply.js / on_client / $ops)."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
Phlex::Reactive::Response.js_ops_json(ops)
|
|
165
|
+
end
|
|
166
|
+
|
|
140
167
|
# Render a built component to HTML for a broadcast, ALWAYS instrumented
|
|
141
168
|
# (issue #185): a Streamable renders through its own render_component (which
|
|
142
169
|
# fires render.phlex_reactive); a plain component renders through the module
|
|
@@ -516,21 +543,6 @@ module Phlex
|
|
|
516
543
|
# instrumentation + pgbus-threading path for the class-level and module-level
|
|
517
544
|
# broadcast_to, so the transport logic has exactly one spelling.)
|
|
518
545
|
|
|
519
|
-
# Validate + serialize broadcast ops: reject focus-class ops (they steal
|
|
520
|
-
# focus in every tab) and an empty chain (a dead broadcast), then return
|
|
521
|
-
# the JSON wire form. Works on a JS chain (inspect .ops) and a raw array.
|
|
522
|
-
def broadcast_js_ops_json(ops)
|
|
523
|
-
pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
|
|
524
|
-
refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
|
|
525
|
-
unless refused.empty?
|
|
526
|
-
raise ArgumentError,
|
|
527
|
-
"broadcast_js_to refuses focus op(s) #{refused.join(", ")} — broadcasting focus " \
|
|
528
|
-
"steals it in every subscriber's tab. Focus is an actor-reply concern (reply.js)."
|
|
529
|
-
end
|
|
530
|
-
|
|
531
|
-
Phlex::Reactive::Response.js_ops_json(ops)
|
|
532
|
-
end
|
|
533
|
-
|
|
534
546
|
def build(model, options)
|
|
535
547
|
new(**(model ? component_args(model, options) : options))
|
|
536
548
|
end
|