phlex-reactive 0.9.5 → 0.10.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 +69 -0
- data/README.md +49 -44
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -1
- data/app/javascript/phlex/reactive/reactive_controller.js +105 -39
- 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/client_bindings.rb +57 -0
- data/lib/phlex/reactive/component/dsl.rb +37 -0
- data/lib/phlex/reactive/component/helpers.rb +174 -219
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component.rb +23 -9
- data/lib/phlex/reactive/show_conditions.rb +249 -0
- data/lib/phlex/reactive/streamable.rb +62 -50
- data/lib/phlex/reactive/test_helpers.rb +9 -1
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -57,13 +57,24 @@ module Phlex
|
|
|
57
57
|
# end
|
|
58
58
|
# end
|
|
59
59
|
#
|
|
60
|
-
# Assembled from
|
|
61
|
-
# public API change:
|
|
62
|
-
# *
|
|
63
|
-
#
|
|
64
|
-
# *
|
|
65
|
-
#
|
|
66
|
-
#
|
|
60
|
+
# Assembled from cohesive concerns (issue #115, restructured in #180) — one
|
|
61
|
+
# include, zero public API change. The include stack, in order:
|
|
62
|
+
# * Phlex::Reactive::Streamable — the render/broadcast/#id surface (mixed in
|
|
63
|
+
# first, so its methods sit below the component's own).
|
|
64
|
+
# * Phlex::Reactive::ClientBindings — the CLIENT-ONLY surface (issue #180),
|
|
65
|
+
# itself Component::DSL (the declaration registries via Component::Registry
|
|
66
|
+
# + from_identity) + Component::Helpers (reply/js, reactive_attrs/root,
|
|
67
|
+
# on/on_client, the field/select/text bindings, reactive_show/filter/
|
|
68
|
+
# compute, and the nested-attributes helpers). ClientBindings is the ONE
|
|
69
|
+
# implementation of that surface, shared with the standalone client-only
|
|
70
|
+
# include; it carries NO token machinery.
|
|
71
|
+
# * Component::Identity — reactive_token + the hot-path ivar precomputation.
|
|
72
|
+
# Its presence is what makes the server-action macros (action/
|
|
73
|
+
# reactive_record/reactive_state) legal here — they raise on a
|
|
74
|
+
# ClientBindings-only class that lacks it.
|
|
75
|
+
# * Component::Lazy — reactive_lazy (deferred initial mount, issue #165).
|
|
76
|
+
# So a full, token-bearing reactive component is a SUPERSET of a client-only
|
|
77
|
+
# one, not a fork.
|
|
67
78
|
module Component
|
|
68
79
|
extend ActiveSupport::Concern
|
|
69
80
|
include Phlex::Reactive::Streamable
|
|
@@ -111,9 +122,12 @@ module Phlex
|
|
|
111
122
|
end
|
|
112
123
|
end
|
|
113
124
|
|
|
114
|
-
|
|
125
|
+
# ClientBindings (issue #180) is the client-only surface (DSL + Helpers),
|
|
126
|
+
# tokenless and Streamable-free. Component includes it as the ONE
|
|
127
|
+
# implementation, then layers Identity + Lazy (and Streamable, above) on
|
|
128
|
+
# top — a token-bearing root is a superset of the client-only one.
|
|
129
|
+
include ClientBindings
|
|
115
130
|
include Identity
|
|
116
|
-
include Helpers
|
|
117
131
|
include Lazy
|
|
118
132
|
end
|
|
119
133
|
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# The ONE conditions language behind reactive_show / reactive_show_targets
|
|
6
|
+
# (issue #180 Phase A). It compiles Ruby-native values into a DNF wire shape
|
|
7
|
+
# and evaluates that shape in Ruby with semantics identical to the client —
|
|
8
|
+
# so first-paint `hidden:` (server) and live toggling (client) can never
|
|
9
|
+
# drift (the shared spec/fixtures/show_predicate_vectors.json proves it).
|
|
10
|
+
#
|
|
11
|
+
# THE VALUE LANGUAGE (used by every if:/if_any:/unless: term):
|
|
12
|
+
# String/Symbol/Integer/Float -> equals (stringified)
|
|
13
|
+
# true / false -> equals "true"/"false" (checkbox state)
|
|
14
|
+
# nil -> equals "" (blank)
|
|
15
|
+
# Array -> in (each stringified; empty raises)
|
|
16
|
+
# (10..) -> gte 10
|
|
17
|
+
# (..10) -> lte 10
|
|
18
|
+
# (...10) -> lt 10
|
|
19
|
+
# (10..20) -> gte 10 AND lte 20 (two terms, one group)
|
|
20
|
+
# Under unless: each term is NEGATED (De Morgan):
|
|
21
|
+
# scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
|
|
22
|
+
# predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
|
|
23
|
+
# to a disjunction (x<a ∨ x>b) that splits the group.
|
|
24
|
+
#
|
|
25
|
+
# THE WIRE (DNF): an ARRAY OF GROUPS. Terms AND within a group; groups OR.
|
|
26
|
+
# [[{ "field"=>"a", "equals"=>"1" }], [{ "field"=>"b", "gte"=>10 }]]
|
|
27
|
+
# A single if: compiles to one group; if_any: to one group per hash; unless:
|
|
28
|
+
# distributes its negated terms into every group (multiplying groups when a
|
|
29
|
+
# bounded-range complement splits). There is NO expression surface: every
|
|
30
|
+
# term is a declared literal predicate — the pre-#180 default-deny posture.
|
|
31
|
+
module ShowConditions
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# Compile if:/if_any:/unless: into DNF groups. Loud validation at render
|
|
35
|
+
# (the pre-#180 posture) — empty conditions, empty Array/group values, a
|
|
36
|
+
# non-Numeric Range endpoint, and if:+if_any: together all raise here so a
|
|
37
|
+
# dead binding fails at render, never silently in the browser.
|
|
38
|
+
def normalize(**kwargs)
|
|
39
|
+
positive = kwargs.slice(:if, :if_any)
|
|
40
|
+
negative = kwargs[:unless]
|
|
41
|
+
|
|
42
|
+
if positive.key?(:if) && positive.key?(:if_any)
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"reactive_show: exactly one of if:/if_any: (unless: composes with either), " \
|
|
45
|
+
"got both"
|
|
46
|
+
end
|
|
47
|
+
raise ArgumentError, "reactive_show needs if:, if_any:, or unless:" unless positive.any? || negative
|
|
48
|
+
|
|
49
|
+
groups = base_groups(positive)
|
|
50
|
+
groups = apply_unless(groups, negative) if negative
|
|
51
|
+
groups
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Evaluate DNF groups against a { field => value(String) } map — true when
|
|
55
|
+
# ANY group's EVERY term matches. Mirrors the client fold exactly; a
|
|
56
|
+
# referenced field absent from `values` reads as "" (fail-closed, same as
|
|
57
|
+
# a blank field).
|
|
58
|
+
# rubocop:disable Style/ItBlockParameter -- nested blocks: `it` would shadow ambiguously
|
|
59
|
+
def match?(groups, values)
|
|
60
|
+
groups.any? { |group| group.all? { |term| term_matches?(term, values) } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Every field named across all groups/terms — drives reactive_values
|
|
64
|
+
# coverage (server first paint only fires when every field is provided).
|
|
65
|
+
def fields(groups)
|
|
66
|
+
groups.flat_map { |group| group.map { |term| term.fetch("field") } }.uniq
|
|
67
|
+
end
|
|
68
|
+
# rubocop:enable Style/ItBlockParameter
|
|
69
|
+
|
|
70
|
+
# --- normalization internals -------------------------------------------
|
|
71
|
+
|
|
72
|
+
# The positive base: if: -> one group; if_any: -> a group per hash; neither
|
|
73
|
+
# (unless: only) -> a single empty "all true" group that unless: negates
|
|
74
|
+
# into.
|
|
75
|
+
def base_groups(positive)
|
|
76
|
+
if positive.key?(:if)
|
|
77
|
+
[group_terms(:if, positive.fetch(:if))]
|
|
78
|
+
elsif positive.key?(:if_any)
|
|
79
|
+
any = positive.fetch(:if_any)
|
|
80
|
+
unless any.is_a?(Array) && any.any?
|
|
81
|
+
raise ArgumentError, "reactive_show if_any: needs at least one group (an Array of hashes)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
any.map { group_terms(:if_any, it) }
|
|
85
|
+
else
|
|
86
|
+
[[]] # unless: only — one empty group; unless distributes its nots in
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One hash -> its ANDed positive terms.
|
|
91
|
+
def group_terms(context, hash)
|
|
92
|
+
unless hash.is_a?(Hash) && hash.any?
|
|
93
|
+
label = context == :if_any ? "each if_any: group needs at least one field" : "if: needs at least one field"
|
|
94
|
+
raise ArgumentError, "reactive_show: #{label} (a Hash of field => value), got #{hash.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
hash.flat_map { |field, value| positive_terms(field, value) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Distribute the negated unless: over every base group, obeying De Morgan.
|
|
101
|
+
# `unless:` negates the AND of its whole hash — ¬(a ∧ b) = ¬a ∨ ¬b — so the
|
|
102
|
+
# negation is a DISJUNCTION: the UNION of each field's alternative
|
|
103
|
+
# term-lists, never their cartesian product. Each field contributes 1
|
|
104
|
+
# alternative (a scalar `not`, or an Array's ∉ = AND-of-nots, or an endless
|
|
105
|
+
# range complement) or 2 (a bounded range, itself a disjunction); those
|
|
106
|
+
# alternatives OR across fields. Distributing that ORed set over the base
|
|
107
|
+
# groups multiplies them: |base| × |alternatives| groups, each base group
|
|
108
|
+
# ANDed with exactly one alternative.
|
|
109
|
+
def apply_unless(groups, negative)
|
|
110
|
+
unless negative.is_a?(Hash) && negative.any?
|
|
111
|
+
raise ArgumentError, "reactive_show unless: needs a Hash of field => value, got #{negative.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
alternatives = negative.flat_map { |field, value| negative_alternatives(field, value) }
|
|
115
|
+
# rubocop:disable Style/ItBlockParameter -- nested block: `it` would shadow `group`
|
|
116
|
+
groups.flat_map do |group|
|
|
117
|
+
alternatives.map { |extra| group + extra }
|
|
118
|
+
end
|
|
119
|
+
# rubocop:enable Style/ItBlockParameter
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- the value language (positive) -------------------------------------
|
|
123
|
+
|
|
124
|
+
# A field => value under if:/if_any: -> a list of ANDed terms (one, or two
|
|
125
|
+
# for a bounded range).
|
|
126
|
+
def positive_terms(field, value)
|
|
127
|
+
name = field.to_s
|
|
128
|
+
case value
|
|
129
|
+
when Range then range_terms(name, value)
|
|
130
|
+
when Array then [membership_term(name, value)]
|
|
131
|
+
else [{ "field" => name, "equals" => equals_literal(value) }]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def equals_literal(value)
|
|
136
|
+
return "" if value.nil?
|
|
137
|
+
|
|
138
|
+
value.to_s
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def membership_term(name, array)
|
|
142
|
+
list = array.map(&:to_s)
|
|
143
|
+
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
144
|
+
|
|
145
|
+
{ "field" => name, "in" => list }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# A Range -> gte / lte / lt terms. Endless -> one term; bounded -> two
|
|
149
|
+
# (gte + lte/lt) in the SAME group. Endpoints must be numbers (a literal
|
|
150
|
+
# numeric threshold, never an expression).
|
|
151
|
+
def range_terms(name, range)
|
|
152
|
+
first = range.begin
|
|
153
|
+
last = range.end
|
|
154
|
+
validate_range_endpoints!(name, first, last)
|
|
155
|
+
|
|
156
|
+
terms = []
|
|
157
|
+
terms << { "field" => name, "gte" => first } unless first.nil?
|
|
158
|
+
if last
|
|
159
|
+
terms << { "field" => name, (range.exclude_end? ? "lt" : "lte") => last }
|
|
160
|
+
end
|
|
161
|
+
terms
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_range_endpoints!(name, first, last)
|
|
165
|
+
[first, last].compact.each do
|
|
166
|
+
next if it.is_a?(Numeric)
|
|
167
|
+
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"reactive_show: #{name.inspect} Range endpoints must be numbers " \
|
|
170
|
+
"(an ordered threshold), got #{it.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# --- the value language (negated, under unless:) -----------------------
|
|
175
|
+
|
|
176
|
+
# A field => value under unless: -> a LIST of alternative term-lists.
|
|
177
|
+
# Scalar/Array negate to a single conjunctive alternative (an Array is
|
|
178
|
+
# ¬(x ∈ set) = AND of nots); a bounded Range complements to TWO
|
|
179
|
+
# alternatives (the disjunction x<a ∨ x>b). An empty Array raises, exactly
|
|
180
|
+
# like the positive membership path (a dead binding must fail at render).
|
|
181
|
+
def negative_alternatives(field, value)
|
|
182
|
+
name = field.to_s
|
|
183
|
+
case value
|
|
184
|
+
when Range then range_complement(name, value)
|
|
185
|
+
when Array
|
|
186
|
+
list = value.map(&:to_s)
|
|
187
|
+
raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
|
|
188
|
+
|
|
189
|
+
[list.map { { "field" => name, "not" => it } }]
|
|
190
|
+
else [[{ "field" => name, "not" => equals_literal(value) }]]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The complement of a range predicate. Endless ranges complement to a
|
|
195
|
+
# single opposite threshold (¬gte→lt, ¬lte→gt, ¬lt→gte) — itself a positive
|
|
196
|
+
# numeric predicate, so a blank/NaN field stays FALSE (fail-closed: the
|
|
197
|
+
# complement never flips a blank to visible). A bounded range complements
|
|
198
|
+
# to two alternatives.
|
|
199
|
+
def range_complement(name, range)
|
|
200
|
+
first = range.begin
|
|
201
|
+
last = range.end
|
|
202
|
+
validate_range_endpoints!(name, first, last)
|
|
203
|
+
|
|
204
|
+
low = first.nil? ? nil : [{ "field" => name, "lt" => first }]
|
|
205
|
+
high =
|
|
206
|
+
if last.nil?
|
|
207
|
+
nil
|
|
208
|
+
elsif range.exclude_end?
|
|
209
|
+
[{ "field" => name, "gte" => last }]
|
|
210
|
+
else
|
|
211
|
+
[{ "field" => name, "gt" => last }]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
[low, high].compact
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- evaluation (mirrors the client) -----------------------------------
|
|
218
|
+
|
|
219
|
+
def term_matches?(term, values)
|
|
220
|
+
field = term.fetch("field")
|
|
221
|
+
value = values.fetch(field, "")
|
|
222
|
+
|
|
223
|
+
if term.key?("equals") then value == term["equals"]
|
|
224
|
+
elsif term.key?("not") then value != term["not"]
|
|
225
|
+
elsif term.key?("in") then term["in"].include?(value)
|
|
226
|
+
else numeric_term_matches?(term, value)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
|
|
231
|
+
# value is fail-closed (never matches). Mirrors the client's
|
|
232
|
+
# numericPredicateMatches (blank/whitespace -> NaN -> false).
|
|
233
|
+
def numeric_term_matches?(term, value)
|
|
234
|
+
trimmed = value.to_s.strip
|
|
235
|
+
return false if trimmed.empty?
|
|
236
|
+
|
|
237
|
+
number = Float(trimmed, exception: false)
|
|
238
|
+
return false if number.nil?
|
|
239
|
+
|
|
240
|
+
if term.key?("gte") then number >= term["gte"]
|
|
241
|
+
elsif term.key?("gt") then number > term["gt"]
|
|
242
|
+
elsif term.key?("lte") then number <= term["lte"]
|
|
243
|
+
elsif term.key?("lt") then number < term["lt"]
|
|
244
|
+
else false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -246,11 +246,11 @@ module Phlex
|
|
|
246
246
|
# transports (Action Cable AND pgbus): it wraps this class-method body,
|
|
247
247
|
# which is the same on either, so pgbus optionality is preserved.
|
|
248
248
|
def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
249
|
-
instrument_broadcast("replace", streamables) do
|
|
249
|
+
instrument_broadcast("replace", streamables, exclude:, visible_to:) do
|
|
250
250
|
component = build(model, options)
|
|
251
251
|
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
252
252
|
*streamables, target: component.id, html: render_component(component),
|
|
253
|
-
**morph_attributes(morph)
|
|
253
|
+
**morph_attributes(morph)
|
|
254
254
|
)
|
|
255
255
|
end
|
|
256
256
|
end
|
|
@@ -261,41 +261,38 @@ module Phlex
|
|
|
261
261
|
# flag, it rides through `attributes:` (the broadcast path has no
|
|
262
262
|
# `method:` kwarg) via morph_attributes.
|
|
263
263
|
def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
264
|
-
instrument_broadcast("update", streamables) do
|
|
264
|
+
instrument_broadcast("update", streamables, exclude:, visible_to:) do
|
|
265
265
|
component = build(model, options)
|
|
266
266
|
::Turbo::StreamsChannel.broadcast_update_to(
|
|
267
267
|
*streamables, target: component.id, html: render_component(component),
|
|
268
|
-
**morph_attributes(morph)
|
|
268
|
+
**morph_attributes(morph)
|
|
269
269
|
)
|
|
270
270
|
end
|
|
271
271
|
end
|
|
272
272
|
|
|
273
273
|
def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
274
|
-
instrument_broadcast("append", streamables) do
|
|
274
|
+
instrument_broadcast("append", streamables, exclude:, visible_to:) do
|
|
275
275
|
component = build(model, options)
|
|
276
276
|
::Turbo::StreamsChannel.broadcast_append_to(
|
|
277
|
-
*streamables, target:, html: render_component(component)
|
|
278
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
277
|
+
*streamables, target:, html: render_component(component)
|
|
279
278
|
)
|
|
280
279
|
end
|
|
281
280
|
end
|
|
282
281
|
|
|
283
282
|
def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
284
|
-
instrument_broadcast("prepend", streamables) do
|
|
283
|
+
instrument_broadcast("prepend", streamables, exclude:, visible_to:) do
|
|
285
284
|
component = build(model, options)
|
|
286
285
|
::Turbo::StreamsChannel.broadcast_prepend_to(
|
|
287
|
-
*streamables, target:, html: render_component(component)
|
|
288
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
286
|
+
*streamables, target:, html: render_component(component)
|
|
289
287
|
)
|
|
290
288
|
end
|
|
291
289
|
end
|
|
292
290
|
|
|
293
291
|
def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
294
|
-
instrument_broadcast("remove", streamables) do
|
|
292
|
+
instrument_broadcast("remove", streamables, exclude:, visible_to:) do
|
|
295
293
|
component = build(model, options)
|
|
296
294
|
::Turbo::StreamsChannel.broadcast_remove_to(
|
|
297
|
-
*streamables, target: component.id
|
|
298
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
295
|
+
*streamables, target: component.id
|
|
299
296
|
)
|
|
300
297
|
end
|
|
301
298
|
end
|
|
@@ -303,8 +300,8 @@ module Phlex
|
|
|
303
300
|
# Push server-side client DOM ops to EVERY subscriber of a stream (issue
|
|
304
301
|
# #97) — the broadcast sibling of Response#js. Rides a `reactive:js`
|
|
305
302
|
# custom stream action over Turbo::StreamsChannel, so it works on Action
|
|
306
|
-
# Cable AND pgbus (
|
|
307
|
-
# exactly like every other broadcast):
|
|
303
|
+
# Cable AND pgbus (exclude:/visible_to: thread to pgbus via
|
|
304
|
+
# instrument_broadcast exactly like every other broadcast):
|
|
308
305
|
#
|
|
309
306
|
# Notifications::Badge.broadcast_js_to(user, :alerts,
|
|
310
307
|
# js.add_class("#bell", "has-unread"), exclude: reactive_connection_id)
|
|
@@ -320,15 +317,14 @@ module Phlex
|
|
|
320
317
|
# actor-reply concern only (Response#js), so it raises ArgumentError here
|
|
321
318
|
# rather than silently shipping a hostile-feeling broadcast.
|
|
322
319
|
def broadcast_js_to(*streamables, ops, exclude: nil, visible_to: nil, target: nil)
|
|
323
|
-
instrument_broadcast("reactive:js", streamables) do
|
|
320
|
+
instrument_broadcast("reactive:js", streamables, exclude:, visible_to:) do
|
|
324
321
|
json = broadcast_js_ops_json(ops)
|
|
325
322
|
::Turbo::StreamsChannel.broadcast_action_to(
|
|
326
323
|
*streamables,
|
|
327
324
|
action: "reactive:js",
|
|
328
325
|
target: target,
|
|
329
326
|
attributes: { data: { reactive_ops: json } },
|
|
330
|
-
render: false
|
|
331
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
327
|
+
render: false
|
|
332
328
|
)
|
|
333
329
|
end
|
|
334
330
|
end
|
|
@@ -356,49 +352,45 @@ module Phlex
|
|
|
356
352
|
# render-per-viewer by definition and can't be shared. This fan-out is
|
|
357
353
|
# for the same payload to many keys.
|
|
358
354
|
def broadcast_replace_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
359
|
-
instrument_broadcast("replace", stream_keys) do
|
|
355
|
+
instrument_broadcast("replace", stream_keys, exclude:, visible_to:) do
|
|
360
356
|
component = build(model, options)
|
|
361
357
|
html = render_component(component)
|
|
362
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
363
358
|
stream_keys.each do
|
|
364
359
|
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
365
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
360
|
+
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
366
361
|
)
|
|
367
362
|
end
|
|
368
363
|
end
|
|
369
364
|
end
|
|
370
365
|
|
|
371
366
|
def broadcast_update_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
372
|
-
instrument_broadcast("update", stream_keys) do
|
|
367
|
+
instrument_broadcast("update", stream_keys, exclude:, visible_to:) do
|
|
373
368
|
component = build(model, options)
|
|
374
369
|
html = render_component(component)
|
|
375
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
376
370
|
stream_keys.each do
|
|
377
371
|
::Turbo::StreamsChannel.broadcast_update_to(
|
|
378
|
-
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
372
|
+
*Array(it), target: component.id, html:, **morph_attributes(morph)
|
|
379
373
|
)
|
|
380
374
|
end
|
|
381
375
|
end
|
|
382
376
|
end
|
|
383
377
|
|
|
384
378
|
def broadcast_append_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
385
|
-
instrument_broadcast("append", stream_keys) do
|
|
379
|
+
instrument_broadcast("append", stream_keys, exclude:, visible_to:) do
|
|
386
380
|
component = build(model, options)
|
|
387
381
|
html = render_component(component)
|
|
388
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
389
382
|
stream_keys.each do
|
|
390
|
-
::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html
|
|
383
|
+
::Turbo::StreamsChannel.broadcast_append_to(*Array(it), target:, html:)
|
|
391
384
|
end
|
|
392
385
|
end
|
|
393
386
|
end
|
|
394
387
|
|
|
395
388
|
def broadcast_prepend_to_each(stream_keys, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
396
|
-
instrument_broadcast("prepend", stream_keys) do
|
|
389
|
+
instrument_broadcast("prepend", stream_keys, exclude:, visible_to:) do
|
|
397
390
|
component = build(model, options)
|
|
398
391
|
html = render_component(component)
|
|
399
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
400
392
|
stream_keys.each do
|
|
401
|
-
::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html
|
|
393
|
+
::Turbo::StreamsChannel.broadcast_prepend_to(*Array(it), target:, html:)
|
|
402
394
|
end
|
|
403
395
|
end
|
|
404
396
|
end
|
|
@@ -406,11 +398,10 @@ module Phlex
|
|
|
406
398
|
# remove has no body — nothing to render. It still builds ONCE to read
|
|
407
399
|
# the component's #id (the target), then loops the cheap channel call.
|
|
408
400
|
def broadcast_remove_to_each(stream_keys, model: nil, exclude: nil, visible_to: nil, **options)
|
|
409
|
-
instrument_broadcast("remove", stream_keys) do
|
|
401
|
+
instrument_broadcast("remove", stream_keys, exclude:, visible_to:) do
|
|
410
402
|
component = build(model, options)
|
|
411
|
-
transport = broadcast_transport_opts(exclude:, visible_to:)
|
|
412
403
|
stream_keys.each do
|
|
413
|
-
::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id
|
|
404
|
+
::Turbo::StreamsChannel.broadcast_remove_to(*Array(it), target: component.id)
|
|
414
405
|
end
|
|
415
406
|
end
|
|
416
407
|
end
|
|
@@ -418,16 +409,46 @@ module Phlex
|
|
|
418
409
|
private
|
|
419
410
|
|
|
420
411
|
# Wrap a broadcast_*_to body in a broadcast.phlex_reactive event (issue
|
|
421
|
-
# #107)
|
|
422
|
-
#
|
|
423
|
-
#
|
|
424
|
-
#
|
|
425
|
-
|
|
412
|
+
# #107) AND thread the pgbus transport opts (issue #187). Payload: the
|
|
413
|
+
# component NAME, the Turbo stream action, and the streamables COUNT —
|
|
414
|
+
# never the model/state. Fires on both transports because it wraps the
|
|
415
|
+
# shared class-method body. Returns the block's value (the broadcast
|
|
416
|
+
# result) so callers are unaffected.
|
|
417
|
+
#
|
|
418
|
+
# exclude:/visible_to: (issue #187): pgbus reads these from thread-locals
|
|
419
|
+
# (Thread.current[:pgbus_broadcast_exclude]/_visible_to), which its
|
|
420
|
+
# Turbo::StreamsChannel#broadcast_stream_to patch consults — NOT from the
|
|
421
|
+
# broadcast_*_to kwargs (turbo-rails swallows unknown kwargs into its
|
|
422
|
+
# render locals, so passing exclude: as a kwarg to StreamsChannel silently
|
|
423
|
+
# drops it: the actor-echo suppression never fired on pgbus). We set the
|
|
424
|
+
# thread-locals around the broadcast (capability-gated on pgbus_streams?)
|
|
425
|
+
# and clear them in ensure. On Action Cable there is no such thread-local
|
|
426
|
+
# reader, so this is a no-op there — pgbus optionality preserved.
|
|
427
|
+
def instrument_broadcast(stream_action, streamables, exclude: nil, visible_to: nil, &)
|
|
426
428
|
Phlex::Reactive.instrument(
|
|
427
429
|
"broadcast",
|
|
428
|
-
{ component: name, stream_action: stream_action, streamables: streamables.size }
|
|
429
|
-
|
|
430
|
-
|
|
430
|
+
{ component: name, stream_action: stream_action, streamables: streamables.size }
|
|
431
|
+
) { with_pgbus_broadcast_opts(exclude:, visible_to:, &) }
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Set the pgbus broadcast thread-locals for the duration of the block,
|
|
435
|
+
# ONLY when streams-capable pgbus is present (the capability gate — an old
|
|
436
|
+
# pgbus / Action Cable never reads these keys, so we skip the work and the
|
|
437
|
+
# ensure entirely). Cleared in ensure so a broadcast never leaks its
|
|
438
|
+
# exclude into a later one on the same thread.
|
|
439
|
+
def with_pgbus_broadcast_opts(exclude:, visible_to:)
|
|
440
|
+
return yield unless Phlex::Reactive.pgbus_streams?
|
|
441
|
+
|
|
442
|
+
prev_exclude = Thread.current[:pgbus_broadcast_exclude]
|
|
443
|
+
prev_visible = Thread.current[:pgbus_broadcast_visible_to]
|
|
444
|
+
Thread.current[:pgbus_broadcast_exclude] = exclude
|
|
445
|
+
Thread.current[:pgbus_broadcast_visible_to] = visible_to
|
|
446
|
+
yield
|
|
447
|
+
ensure
|
|
448
|
+
if Phlex::Reactive.pgbus_streams?
|
|
449
|
+
Thread.current[:pgbus_broadcast_exclude] = prev_exclude
|
|
450
|
+
Thread.current[:pgbus_broadcast_visible_to] = prev_visible
|
|
451
|
+
end
|
|
431
452
|
end
|
|
432
453
|
|
|
433
454
|
# Validate + serialize broadcast ops: reject focus-class ops (they steal
|
|
@@ -463,15 +484,6 @@ module Phlex
|
|
|
463
484
|
morph ? { attributes: { method: "morph" } } : {}
|
|
464
485
|
end
|
|
465
486
|
|
|
466
|
-
# Only include transport opts that were actually given, so on Action
|
|
467
|
-
# Cable (which doesn't accept them) the common no-opts call is unchanged.
|
|
468
|
-
def broadcast_transport_opts(exclude:, visible_to:)
|
|
469
|
-
opts = {}
|
|
470
|
-
opts[:exclude] = exclude unless exclude.nil?
|
|
471
|
-
opts[:visible_to] = visible_to unless visible_to.nil?
|
|
472
|
-
opts
|
|
473
|
-
end
|
|
474
|
-
|
|
475
487
|
def renderer
|
|
476
488
|
Phlex::Reactive.renderer
|
|
477
489
|
end
|
|
@@ -276,7 +276,15 @@ module Phlex
|
|
|
276
276
|
end
|
|
277
277
|
|
|
278
278
|
if @response.render_self? && streams.none? { it.include?("data-reactive-token-value") }
|
|
279
|
-
|
|
279
|
+
# Mirror the endpoint (issue #180): a self-render reply gets the full
|
|
280
|
+
# replace; a companion-only reply.with (no subject_component) gets a
|
|
281
|
+
# token-only refresh instead.
|
|
282
|
+
streams =
|
|
283
|
+
if @response.subject_component
|
|
284
|
+
[@component.to_stream_replace, *streams]
|
|
285
|
+
else
|
|
286
|
+
[*streams, @component.to_stream_token]
|
|
287
|
+
end
|
|
280
288
|
end
|
|
281
289
|
streams
|
|
282
290
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -142,6 +142,7 @@ files:
|
|
|
142
142
|
- lib/phlex/reactive.rb
|
|
143
143
|
- lib/phlex/reactive/authorization.rb
|
|
144
144
|
- lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md
|
|
145
|
+
- lib/phlex/reactive/client_bindings.rb
|
|
145
146
|
- lib/phlex/reactive/component.rb
|
|
146
147
|
- lib/phlex/reactive/component/dsl.rb
|
|
147
148
|
- lib/phlex/reactive/component/helpers.rb
|
|
@@ -168,6 +169,7 @@ files:
|
|
|
168
169
|
- lib/phlex/reactive/param_schema.rb
|
|
169
170
|
- lib/phlex/reactive/reply.rb
|
|
170
171
|
- lib/phlex/reactive/response.rb
|
|
172
|
+
- lib/phlex/reactive/show_conditions.rb
|
|
171
173
|
- lib/phlex/reactive/stream.rb
|
|
172
174
|
- lib/phlex/reactive/streamable.rb
|
|
173
175
|
- lib/phlex/reactive/test_helpers.rb
|