phlex-reactive 0.13.2 → 0.13.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 +182 -0
- data/README.md +155 -12
- data/app/controllers/phlex/reactive/actions_controller.rb +72 -3
- data/app/javascript/phlex/reactive/compute.js +9 -1
- data/app/javascript/phlex/reactive/compute.min.js.map +2 -2
- data/app/javascript/phlex/reactive/confirm_predicate.js +7 -1
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +2 -2
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +676 -69
- 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 +7 -0
- data/lib/phlex/reactive/component/helpers.rb +69 -22
- data/lib/phlex/reactive/component.rb +2 -1
- data/lib/phlex/reactive/engine.rb +1 -0
- data/lib/phlex/reactive/js.rb +44 -10
- data/lib/phlex/reactive/param_schema.rb +16 -0
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/test_helpers.rb +10 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
|
@@ -389,6 +389,13 @@ module Phlex
|
|
|
389
389
|
# inputs: { title: :string, qty: :number },
|
|
390
390
|
# outputs: %i[title_preview char_count]
|
|
391
391
|
#
|
|
392
|
+
# A CHECKBOX contributes its checked state, never its constant .value
|
|
393
|
+
# (issue #262): 1/0 as a :number, "true"/"false" as a :string, and a
|
|
394
|
+
# real boolean as a :boolean. It wins over the hidden companion Rails
|
|
395
|
+
# renders before it. A radio group contributes its checked radio's value.
|
|
396
|
+
#
|
|
397
|
+
# reactive_compute :total, inputs: [:price, { gift_wrap: :boolean }], outputs: %i[total]
|
|
398
|
+
#
|
|
392
399
|
# An output with no matching form field writes to its reactive_text(:name)
|
|
393
400
|
# node (textContent); a declared input also mirrors into its own text node
|
|
394
401
|
# with no reducer. The array form's wire stays byte-identical.
|
|
@@ -225,6 +225,16 @@ module Phlex
|
|
|
225
225
|
"keydown.esc->reactive#listnavClose"
|
|
226
226
|
].freeze
|
|
227
227
|
|
|
228
|
+
# Roving-focus mode (issue #271): Enter/Space are the focused item's
|
|
229
|
+
# native activation and Escape is the caller's own on_client binding,
|
|
230
|
+
# so only the movement keys are bound.
|
|
231
|
+
LISTNAV_FOCUS_ACTIONS = [
|
|
232
|
+
"keydown.down->reactive#listnavNext",
|
|
233
|
+
"keydown.up->reactive#listnavPrev",
|
|
234
|
+
"keydown.home->reactive#listnavFirst",
|
|
235
|
+
"keydown.end->reactive#listnavLast"
|
|
236
|
+
].freeze
|
|
237
|
+
|
|
228
238
|
# Event modifiers (issue #80) — window:, once:, outside:, throttle: are
|
|
229
239
|
# RESERVED keyword names on on() (no longer usable as free action params):
|
|
230
240
|
#
|
|
@@ -376,6 +386,17 @@ module Phlex
|
|
|
376
386
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
377
387
|
# the forced type="button".
|
|
378
388
|
#
|
|
389
|
+
# Issue #271: each call emits ONE self-describing binding record —
|
|
390
|
+
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
391
|
+
# carrying its own descriptor, flags and confirm, so several bindings on
|
|
392
|
+
# one element compose through mix: the data-action descriptors token-join,
|
|
393
|
+
# the records space-join (every literal space inside a record is written
|
|
394
|
+
# as \u0020, so the join is unambiguous), and the client runs only the
|
|
395
|
+
# record whose descriptor matches the firing event:
|
|
396
|
+
# div(**mix(reactive_root,
|
|
397
|
+
# on_client(:click, js.hide("#menu"), outside: true),
|
|
398
|
+
# on_client("keydown.esc", js.hide("#menu"))))
|
|
399
|
+
#
|
|
379
400
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
380
401
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
381
402
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -402,29 +423,21 @@ module Phlex
|
|
|
402
423
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
403
424
|
end
|
|
404
425
|
window_bound = window || outside
|
|
426
|
+
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
405
427
|
attrs = {
|
|
406
428
|
data: {
|
|
407
429
|
action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
|
|
408
|
-
|
|
430
|
+
# Space-free (issue #271): compact JSON has no structural
|
|
431
|
+
# whitespace, so every space is inside a string, where \u0020
|
|
432
|
+
# parses to the same string — and mix's space-join stays splittable.
|
|
433
|
+
reactive_ops_param: record.to_json.gsub(" ", '\u0020')
|
|
409
434
|
}
|
|
410
435
|
}
|
|
411
|
-
# STRING "true", not boolean — same Phlex valueless-attribute trap as
|
|
412
|
-
# on()'s flags above.
|
|
413
|
-
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
414
|
-
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
415
436
|
# Issue #228: mark a clipboard-reading trigger so the client can gate
|
|
416
437
|
# its visibility on availability — on connect the controller sets
|
|
417
438
|
# hidden = !navigator.clipboard.readText. Render the trigger `hidden`
|
|
418
439
|
# for reveal-when-available (a dead paste button never shows).
|
|
419
440
|
attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
|
|
420
|
-
# Issue #178: confirm: gates the client-op chain behind the SAME
|
|
421
|
-
# overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
|
|
422
|
-
# the identical data-reactive-confirm-param; the client's runOps prompts
|
|
423
|
-
# via confirmResolver BEFORE applying the ops (a falsy resolve cancels
|
|
424
|
-
# the chain), so a destructive client op gets the themed dialog with no
|
|
425
|
-
# round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
|
|
426
|
-
# apply_confirm! branches String vs Hash for both on and on_client.
|
|
427
|
-
apply_confirm!(attrs[:data], confirm) if confirm
|
|
428
441
|
attrs[:type] = "button" if event == "click" && !window_bound
|
|
429
442
|
attrs
|
|
430
443
|
end
|
|
@@ -449,7 +462,7 @@ module Phlex
|
|
|
449
462
|
# the baseline is the DOM's own `defaultValue`/`defaultChecked`/
|
|
450
463
|
# `defaultSelected` from the last server render (dirty = current ≠ default).
|
|
451
464
|
# The descriptor deep-merges via mix, so a caller's own data-action is
|
|
452
|
-
# token-joined, not clobbered (
|
|
465
|
+
# token-joined, not clobbered (AGENTS.md Never-Do #8).
|
|
453
466
|
def reactive_field(param, **attrs)
|
|
454
467
|
# Issue #184: the removed dirty: kwarg lands in **attrs — catch it and
|
|
455
468
|
# print the reactive_dirty rewrite.
|
|
@@ -678,13 +691,23 @@ module Phlex
|
|
|
678
691
|
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
679
692
|
# (selection stays a signed action), Escape clears. Combine with other
|
|
680
693
|
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
694
|
+
#
|
|
695
|
+
# `focus: true` (issue #271) is ROVING-FOCUS mode for a menu container:
|
|
696
|
+
# Arrow Down/Up move real focus among the items (wrapping), Home/End jump
|
|
697
|
+
# to the first/last; no highlight attr is written. The item selector
|
|
698
|
+
# defaults to [role=menuitem]. Enter/Space stay native activation and
|
|
699
|
+
# Escape is yours to bind (on_client("keydown.esc", ...) composes via mix):
|
|
700
|
+
#
|
|
701
|
+
# ul(role: "menu", **mix(reactive_listnav(focus: true), data: { testid: "menu" }))
|
|
702
|
+
def reactive_listnav(option_selector = nil, focus: false)
|
|
703
|
+
option_selector ||= focus ? "[role=menuitem]" : "[role=option]"
|
|
704
|
+
data = {
|
|
705
|
+
action: (focus ? LISTNAV_FOCUS_ACTIONS : LISTNAV_ACTIONS).join(" "),
|
|
706
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
687
707
|
}
|
|
708
|
+
# STRING "true" — Phlex renders a boolean true as a valueless attribute.
|
|
709
|
+
data[:reactive_listnav_focus_param] = "true" if focus
|
|
710
|
+
{ data: }
|
|
688
711
|
end
|
|
689
712
|
|
|
690
713
|
# Tag-chip input (issue #203) — the composed combobox/tags primitive.
|
|
@@ -1232,8 +1255,9 @@ module Phlex
|
|
|
1232
1255
|
# The inputs param wire (issue #104). Untyped (array form) → a JSON ARRAY of
|
|
1233
1256
|
# names, byte-identical to the shipped wire so the client keeps its numeric
|
|
1234
1257
|
# coercion. Typed (hash form) → a JSON OBJECT of name→type
|
|
1235
|
-
# ({"title":"string","qty":"number"}) so the client reads a :string raw
|
|
1236
|
-
# coerces a :number through Number
|
|
1258
|
+
# ({"title":"string","qty":"number"}) so the client reads a :string raw,
|
|
1259
|
+
# coerces a :number through Number and reads a :boolean as a checkbox's
|
|
1260
|
+
# checked state (issue #262).
|
|
1237
1261
|
def compute_inputs_param(definition)
|
|
1238
1262
|
types = definition.input_types
|
|
1239
1263
|
return definition.inputs.map(&:to_s).to_json if types.nil?
|
|
@@ -1390,6 +1414,29 @@ module Phlex
|
|
|
1390
1414
|
# Shared by on and on_client so both paths speak the same three forms. The
|
|
1391
1415
|
# predicate is soft-validation UX, NOT authorization — a user can bypass it;
|
|
1392
1416
|
# the action still hits the endpoint's real authorize/default-deny.
|
|
1417
|
+
# One on_client binding record (issue #271). Flags and confirm live IN
|
|
1418
|
+
# the record — never as element-wide params, which a sibling binding
|
|
1419
|
+
# would inherit. Issue #178: confirm: gates the chain behind the SAME
|
|
1420
|
+
# overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
|
|
1421
|
+
# client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
|
|
1422
|
+
# CONDITIONAL, compiled by the shared compile_conditional_confirm.
|
|
1423
|
+
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
1424
|
+
record = { "on" => event }
|
|
1425
|
+
record["window"] = true if window_bound
|
|
1426
|
+
record["outside"] = true if outside
|
|
1427
|
+
# A mix-ed regular sibling keeps calling runOps after Stimulus drops
|
|
1428
|
+
# this binding's :once listener — the client skips a spent once record.
|
|
1429
|
+
record["once"] = true if once
|
|
1430
|
+
case confirm
|
|
1431
|
+
when nil, false then nil
|
|
1432
|
+
when String then record["confirm"] = confirm
|
|
1433
|
+
when Hash then record["confirmWhen"] = compile_conditional_confirm(confirm)
|
|
1434
|
+
else apply_confirm!({}, confirm) # raises the shared guided error
|
|
1435
|
+
end
|
|
1436
|
+
record["ops"] = ops.ops
|
|
1437
|
+
record
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1393
1440
|
def apply_confirm!(data, confirm)
|
|
1394
1441
|
case confirm
|
|
1395
1442
|
when String
|
|
@@ -102,7 +102,8 @@ module Phlex
|
|
|
102
102
|
# `input_types` (issue #104) is nil for the ARRAY input form (untyped ⇒ the
|
|
103
103
|
# client coerces every input through Number, the shipped behavior) and a
|
|
104
104
|
# { name => type } hash for the typed HASH form (:string reads the field
|
|
105
|
-
# value raw, :number coerces
|
|
105
|
+
# value raw, :number coerces, :boolean reads a checkbox's checked state —
|
|
106
|
+
# issue #262). `inputs` stays the ordered name list either
|
|
106
107
|
# way, so iteration order is preserved and the array-form wire is unchanged.
|
|
107
108
|
#
|
|
108
109
|
# `mirror` (issue #159) is nil when undeclared, else a { name => [id
|
|
@@ -112,6 +112,7 @@ module Phlex
|
|
|
112
112
|
config.to_prepare do
|
|
113
113
|
Phlex::Reactive::Streamable.reset_all_view_contexts!
|
|
114
114
|
Phlex::Reactive.reset_stream_builder!
|
|
115
|
+
Phlex::Reactive::Pending::Markup.reset!
|
|
115
116
|
end
|
|
116
117
|
|
|
117
118
|
# Boot-time guard (issue #26): warn if the action path doesn't resolve to
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -110,17 +110,25 @@ module Phlex
|
|
|
110
110
|
# the next frame `from`→`to` swaps, and the whole set is awaited via
|
|
111
111
|
# `animationend` (with a setTimeout fallback so a non-animated element never
|
|
112
112
|
# hangs the op chain). Omit it for the instant flip.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
#
|
|
114
|
+
# `expanded:` (issue #271) — a disclosure trigger to keep honest: the
|
|
115
|
+
# client sets its aria-expanded from the op's INTENDED state (show →
|
|
116
|
+
# "true", hide → "false", toggle → the pre-flip hidden state), before any
|
|
117
|
+
# transition runs. Resolved with the op's own scoping (`:root` or a
|
|
118
|
+
# selector, document-wide under global: true). Pair it with ONE disclosure
|
|
119
|
+
# target — with several, the last one wins.
|
|
120
|
+
# js.toggle("#menu", expanded: "#menu-trigger")
|
|
121
|
+
|
|
122
|
+
def show(to, global: false, transition: nil, expanded: nil)
|
|
123
|
+
append("show", target_args(to, global:, transition:, expanded:))
|
|
116
124
|
end
|
|
117
125
|
|
|
118
|
-
def hide(to, global: false, transition: nil)
|
|
119
|
-
append("hide", target_args(to, global:, transition:))
|
|
126
|
+
def hide(to, global: false, transition: nil, expanded: nil)
|
|
127
|
+
append("hide", target_args(to, global:, transition:, expanded:))
|
|
120
128
|
end
|
|
121
129
|
|
|
122
|
-
def toggle(to, global: false, transition: nil)
|
|
123
|
-
append("toggle", target_args(to, global:, transition:))
|
|
130
|
+
def toggle(to, global: false, transition: nil, expanded: nil)
|
|
131
|
+
append("toggle", target_args(to, global:, transition:, expanded:))
|
|
124
132
|
end
|
|
125
133
|
|
|
126
134
|
# --- Classes ---
|
|
@@ -143,6 +151,11 @@ module Phlex
|
|
|
143
151
|
# The value is stringified (a Phlex-style flag rides as the string "true",
|
|
144
152
|
# never a valueless attribute). The name is checked against the allowlist at
|
|
145
153
|
# build time — an event-handler, URL-bearing, or style name raises here.
|
|
154
|
+
#
|
|
155
|
+
# toggle_attr(to, name, on, off) (issue #271) flips BETWEEN two values
|
|
156
|
+
# instead of toggling presence — current == on ? off : on, so an absent
|
|
157
|
+
# attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
|
|
158
|
+
# js.toggle_attr("#trigger", "aria-expanded", "true", "false")
|
|
146
159
|
|
|
147
160
|
def set_attr(to, name, value, global: false)
|
|
148
161
|
append("set_attr", attr_args(to, name, global:, value:))
|
|
@@ -152,8 +165,11 @@ module Phlex
|
|
|
152
165
|
append("remove_attr", attr_args(to, name, global:))
|
|
153
166
|
end
|
|
154
167
|
|
|
155
|
-
def toggle_attr(to, name, global: false)
|
|
156
|
-
|
|
168
|
+
def toggle_attr(to, name, *values, global: false)
|
|
169
|
+
args = attr_args(to, name, global:)
|
|
170
|
+
return append("toggle_attr", args) if values.empty?
|
|
171
|
+
|
|
172
|
+
append("toggle_attr", args.merge("values" => toggle_values(name, values)).freeze)
|
|
157
173
|
end
|
|
158
174
|
|
|
159
175
|
# --- Focus (issue #96) ---
|
|
@@ -290,13 +306,31 @@ module Phlex
|
|
|
290
306
|
self.class.new([*@ops, [name, args].freeze].freeze)
|
|
291
307
|
end
|
|
292
308
|
|
|
293
|
-
def target_args(to, global:, transition: nil)
|
|
309
|
+
def target_args(to, global:, transition: nil, expanded: nil)
|
|
294
310
|
args = { "to" => normalize_target(to) }
|
|
295
311
|
args["global"] = true if global
|
|
296
312
|
args["transition"] = normalize_transition(transition) if transition
|
|
313
|
+
args["expanded"] = normalize_target(expanded) unless expanded.nil?
|
|
297
314
|
args.freeze
|
|
298
315
|
end
|
|
299
316
|
|
|
317
|
+
# The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
|
|
318
|
+
# half-specified pair or one that could never change anything.
|
|
319
|
+
def toggle_values(name, values)
|
|
320
|
+
unless values.size == 2
|
|
321
|
+
raise ArgumentError,
|
|
322
|
+
"#{self.class}: toggle_attr(#{name.to_s.inspect}) takes no values (presence toggle) " \
|
|
323
|
+
"or two values to flip between, got #{values.size}: #{values.inspect}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
on, off = values.map(&:to_s)
|
|
327
|
+
if on == off
|
|
328
|
+
raise ArgumentError, "#{self.class}: toggle_attr(#{name.to_s.inspect}) values must differ, got #{on.inspect} twice"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
[on, off].freeze
|
|
332
|
+
end
|
|
333
|
+
|
|
300
334
|
# An attr op's args: the target, the allowlisted name, an optional
|
|
301
335
|
# (stringified) value. The name is validated LOUDLY at build time.
|
|
302
336
|
def attr_args(to, name, global:, value: :__none)
|
|
@@ -174,6 +174,22 @@ module Phlex
|
|
|
174
174
|
coerced.equal?(DROP) ? {} : coerced
|
|
175
175
|
end
|
|
176
176
|
|
|
177
|
+
# The key this DECLARATION holds for `name` if it declares it as an ARRAY
|
|
178
|
+
# param, else nil. Reads the declaration, never the incoming params, so the
|
|
179
|
+
# endpoint's empty-group announcement (issue #258) can only ever fill
|
|
180
|
+
# something the action asked for.
|
|
181
|
+
#
|
|
182
|
+
# A key that is neither a String nor a Symbol stays unresolved on purpose:
|
|
183
|
+
# `compile` accepts `{ 0 => [:string] }`, but `coerce_hash` raises on
|
|
184
|
+
# `0.to_sym` the moment that key is PRESENT, and the announcement is what
|
|
185
|
+
# would make it present. Resolving it would turn a request that answers 200
|
|
186
|
+
# and fills nothing into a 500.
|
|
187
|
+
def array_param(name)
|
|
188
|
+
name = name.to_s
|
|
189
|
+
key, type = @schema.find { |k, _| (k.is_a?(String) || k.is_a?(Symbol)) && k.to_s == name }
|
|
190
|
+
key if type.is_a?(Array)
|
|
191
|
+
end
|
|
192
|
+
|
|
177
193
|
private
|
|
178
194
|
|
|
179
195
|
# Coerce a value against a declared type. Arrays accept both a real JSON
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Pending
|
|
6
|
+
# The opt-in pending MARKUP hook (issue #249). The #248 markers dim a live
|
|
7
|
+
# row; some apps want the pending state to be honest markup instead — a
|
|
8
|
+
# real "Queued" badge, a button that is genuinely gone. A row component
|
|
9
|
+
# opts in by defining `pending_template` (private is fine), and
|
|
10
|
+
# reply.pending then ALSO replaces the row with that markup:
|
|
11
|
+
#
|
|
12
|
+
# class TransferRow < ApplicationComponent
|
|
13
|
+
# def view_template = li(id:, **reactive_attrs) { ...; button(**on(:retry)) { "Retry" } }
|
|
14
|
+
#
|
|
15
|
+
# private
|
|
16
|
+
#
|
|
17
|
+
# def pending_template = li(id:, **reactive_attrs) { ...; span(class: "badge") { "Queued" } }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# It is a TEMPLATE method, not a content method like `deferred_placeholder`
|
|
21
|
+
# (#165): there the gem owns the shell div and the component supplies what
|
|
22
|
+
# goes inside; here the row's own root is swapped, so the hook renders it.
|
|
23
|
+
#
|
|
24
|
+
# ## Why a subclass
|
|
25
|
+
#
|
|
26
|
+
# Phlex 2 compiles templates per class, so the swap cannot be a per-instance
|
|
27
|
+
# method. Each opted-in row class gets ONE memoized anonymous subclass whose
|
|
28
|
+
# view_template is the pending template. Two contracts make that safe:
|
|
29
|
+
#
|
|
30
|
+
# * The variant's `.name` is the PARENT's. A row that includes Component
|
|
31
|
+
# signs its class name into its token; an anonymous name cannot
|
|
32
|
+
# constantize, so the row's next action would 400.
|
|
33
|
+
# * The memo holds classes derived from reloadable ones, so the engine
|
|
34
|
+
# resets it on Rails code reload (config.to_prepare), exactly like the
|
|
35
|
+
# view-context and stream-builder memos.
|
|
36
|
+
#
|
|
37
|
+
# ## Un-pending is the settle's job
|
|
38
|
+
#
|
|
39
|
+
# The markers are attributes, so any settle can strip them. Swapped markup
|
|
40
|
+
# is not: only a settle that REPLACES or REMOVES the row puts it back. A
|
|
41
|
+
# flash-only settle, `finish: true` or a failed job clears the markers but
|
|
42
|
+
# leaves the pending markup in place.
|
|
43
|
+
module Markup
|
|
44
|
+
HOOK = :pending_template
|
|
45
|
+
|
|
46
|
+
# The root start tag of the rendered markup, walked quote-aware: Phlex
|
|
47
|
+
# double-quotes every attribute value and leaves `>` raw inside it
|
|
48
|
+
# (`data-action="click->reactive#dispatch"`), so the tag must not end at
|
|
49
|
+
# the first `>`.
|
|
50
|
+
ROOT_TAG = %r{\A\s*<[^\s>/]+(?:\s+[^\s=>/]+(?:="[^"]*")?)*}
|
|
51
|
+
|
|
52
|
+
# Keyed weakly on the row class, so a class Zeitwerk replaced is not
|
|
53
|
+
# pinned between resets. A WeakKeyMap, not a WeakMap: nothing else holds
|
|
54
|
+
# the variant, so a weak VALUE would let GC drop the memo.
|
|
55
|
+
@variants = ObjectSpace::WeakKeyMap.new
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# The row's pending markup as HTML, or nil when the row has no hook
|
|
60
|
+
# (or the target is a bare DOM id String — there is no component to
|
|
61
|
+
# render). Rendered through the PARENT's render_component: the variant
|
|
62
|
+
# is never registered with Streamable, so this reuses the parent's
|
|
63
|
+
# memoized, reload-reset view context and its instrumentation name.
|
|
64
|
+
def render(row)
|
|
65
|
+
return nil unless row.is_a?(Phlex::Reactive::Streamable) && row.respond_to?(HOOK, true)
|
|
66
|
+
|
|
67
|
+
html = row.class.render_component(variant_instance(row))
|
|
68
|
+
assert_keeps_id!(row, html)
|
|
69
|
+
html
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The replace stream that swaps one row to its pending markup.
|
|
73
|
+
def stream(target_id, html)
|
|
74
|
+
Phlex::Reactive::Stream.wrap(
|
|
75
|
+
Phlex::Reactive.stream_builder.replace(target_id, html: html.html_safe),
|
|
76
|
+
action: "replace", target: target_id, renders_root: true
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def variant_for(row_class)
|
|
81
|
+
@mutex.synchronize { @variants[row_class] ||= build_variant(row_class) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Called from the engine's config.to_prepare (Rails code reload).
|
|
85
|
+
def reset!
|
|
86
|
+
@mutex.synchronize { @variants = ObjectSpace::WeakKeyMap.new }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def build_variant(row_class)
|
|
92
|
+
Class.new(row_class) do
|
|
93
|
+
def self.name = superclass.name
|
|
94
|
+
def self.inspect = "#{superclass.name}(pending)"
|
|
95
|
+
def self.to_s = inspect
|
|
96
|
+
|
|
97
|
+
def view_template = pending_template
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A variant instance carrying the built row's state. allocate + copy
|
|
102
|
+
# rather than re-running initialize: the row may have been handed to
|
|
103
|
+
# reply.pending already built, with arguments the gem never saw.
|
|
104
|
+
def variant_instance(row)
|
|
105
|
+
variant = variant_for(row.class).allocate
|
|
106
|
+
row.instance_variables.each { variant.instance_variable_set(it, row.instance_variable_get(it)) }
|
|
107
|
+
variant
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A pending template that drops the row's id leaves the settle nothing
|
|
111
|
+
# to target: its replace/remove would miss and the row would say
|
|
112
|
+
# "Queued" forever. Fail at the action, before anything is enqueued.
|
|
113
|
+
# Only the ROOT start tag counts — the id on a data- attribute or a
|
|
114
|
+
# nested child is not a target the settle can swap (see ROOT_TAG).
|
|
115
|
+
def assert_keeps_id!(row, html)
|
|
116
|
+
root_tag = html[ROOT_TAG].to_s
|
|
117
|
+
return if root_tag.match?(/\sid="#{Regexp.escape(ERB::Util.html_escape(row.id))}"/)
|
|
118
|
+
|
|
119
|
+
raise Phlex::Reactive::Error,
|
|
120
|
+
"#{row.class.name}#pending_template must render the row's root with its id " \
|
|
121
|
+
"(id=\"#{row.id}\") — the settle targets that id to replace or remove the row. " \
|
|
122
|
+
"Render the root as in view_template, e.g. li(id:, **reactive_attrs) { ... }."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -92,8 +92,10 @@ module Phlex
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# One recorded pending segment: the handle plus the DOM ids that were
|
|
95
|
-
# marked pending (the endpoint emits one marker stream per id)
|
|
96
|
-
|
|
95
|
+
# marked pending (the endpoint emits one marker stream per id), and — in
|
|
96
|
+
# step with target_ids — each row's pending markup (issue #249), nil for
|
|
97
|
+
# a row without the pending_template hook.
|
|
98
|
+
Segment = Data.define(:handle, :target_ids, :markup)
|
|
97
99
|
|
|
98
100
|
# The attribute apps style. Set alongside aria-busy on every pending
|
|
99
101
|
# target AND on the container, so one CSS rule covers both:
|
|
@@ -126,7 +128,8 @@ module Phlex
|
|
|
126
128
|
# or re-queries, so a concurrent write could make the marked rows and
|
|
127
129
|
# the enqueued jobs disagree.
|
|
128
130
|
list = materialize(records)
|
|
129
|
-
|
|
131
|
+
rows = resolve_rows(container, list, collection)
|
|
132
|
+
targets = rows.map { it.is_a?(String) ? it : it.id }
|
|
130
133
|
|
|
131
134
|
unless Phlex::Reactive.settle_capable?
|
|
132
135
|
warn_no_lane
|
|
@@ -134,9 +137,12 @@ module Phlex
|
|
|
134
137
|
return nil
|
|
135
138
|
end
|
|
136
139
|
|
|
140
|
+
# Rendered BEFORE the enqueue, so a broken pending_template fails the
|
|
141
|
+
# action (and rolls it back) before any job exists.
|
|
142
|
+
markup = rows.map { Markup.render(it) }
|
|
137
143
|
handle = build_handle(container, collection, targets, peers)
|
|
138
144
|
with_handle(handle) { run_enqueue(list, job, args, enqueue, targets) }
|
|
139
|
-
Segment.new(handle:, target_ids: targets)
|
|
145
|
+
Segment.new(handle:, target_ids: targets, markup:)
|
|
140
146
|
end
|
|
141
147
|
|
|
142
148
|
# One record, or an enumerable of them, as an Array — never re-walked.
|
|
@@ -149,9 +155,13 @@ module Phlex
|
|
|
149
155
|
|
|
150
156
|
# The wire streams for one segment, in apply order: the per-target
|
|
151
157
|
# pending markers FIRST (so the UI stops lying immediately), then the
|
|
152
|
-
# single subscription directive.
|
|
158
|
+
# single subscription directive. A row with pending markup is replaced
|
|
159
|
+
# just before its marker, so the marker lands on the swapped node.
|
|
153
160
|
def streams_for(segment)
|
|
154
|
-
streams = segment.target_ids.
|
|
161
|
+
streams = segment.target_ids.each_with_index.flat_map do |id, index|
|
|
162
|
+
html = segment.markup[index]
|
|
163
|
+
html ? [Markup.stream(id, html), marker_stream(id)] : [marker_stream(id)]
|
|
164
|
+
end
|
|
155
165
|
streams << marker_stream(segment.handle.anchor)
|
|
156
166
|
streams << directive_stream(segment.handle)
|
|
157
167
|
streams
|
|
@@ -162,24 +172,18 @@ module Phlex
|
|
|
162
172
|
# exact id to tear the subscription down.
|
|
163
173
|
def source_id(anchor) = "reactive-defer-src-#{anchor}"
|
|
164
174
|
|
|
165
|
-
# Resolve the DOM id a row component would render for `model`, WITHOUT
|
|
166
|
-
# rendering it (build is cheap — #id must be render-context-free, that
|
|
167
|
-
# is the Streamable#id contract).
|
|
168
|
-
def row_dom_id(definition, model)
|
|
169
|
-
return model if model.is_a?(String)
|
|
170
|
-
|
|
171
|
-
definition.item.send(:build, model, {}).id
|
|
172
|
-
end
|
|
173
|
-
|
|
174
175
|
private
|
|
175
176
|
|
|
176
|
-
# Each pending target, as a
|
|
177
|
-
# the
|
|
178
|
-
#
|
|
179
|
-
|
|
177
|
+
# Each pending target, as a row component (its #id is the target, and
|
|
178
|
+
# it renders the pending markup when it has the hook) or a bare DOM id
|
|
179
|
+
# String. With `in:` the rows resolve through the collection declaration
|
|
180
|
+
# (build is cheap — #id must be render-context-free, the Streamable#id
|
|
181
|
+
# contract); without it every entry must already be a Streamable
|
|
182
|
+
# component.
|
|
183
|
+
def resolve_rows(container, list, collection)
|
|
180
184
|
if collection
|
|
181
185
|
definition = Phlex::Reactive::Collections.definition!(container, collection)
|
|
182
|
-
return list.map {
|
|
186
|
+
return list.map { it.is_a?(String) ? it : definition.item.send(:build, it, {}) }
|
|
183
187
|
end
|
|
184
188
|
|
|
185
189
|
list.map do
|
|
@@ -190,7 +194,7 @@ module Phlex
|
|
|
190
194
|
"Streamable component instance (its #id is the target)"
|
|
191
195
|
end
|
|
192
196
|
|
|
193
|
-
it
|
|
197
|
+
it
|
|
194
198
|
end
|
|
195
199
|
end
|
|
196
200
|
|
|
@@ -64,10 +64,17 @@ module Phlex
|
|
|
64
64
|
# `:file` param is present, issue #34): token + act flat, params bracketed.
|
|
65
65
|
# No JSON Content-Type — Rails' test `post` builds the multipart body from
|
|
66
66
|
# the nested Hash (files ride as UploadedFile values).
|
|
67
|
-
|
|
67
|
+
#
|
|
68
|
+
# `empty_groups:` names the `[]` groups the client cleared (issue #258). A
|
|
69
|
+
# form body cannot carry an empty array, so the client leaves those keys
|
|
70
|
+
# out and announces them in a field of its own; passing the names here is
|
|
71
|
+
# how a spec reproduces that request. Omit it and the body is exactly what
|
|
72
|
+
# it was before the field existed.
|
|
73
|
+
def post_reactive_multipart(component_or_class, act, params: {}, payload: {}, empty_groups: [])
|
|
68
74
|
token = reactive_action_token(component_or_class, payload)
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
body = { token:, act:, params: }
|
|
76
|
+
body[:empty_groups] = empty_groups if empty_groups.present?
|
|
77
|
+
post Phlex::Reactive.action_path, params: body,
|
|
71
78
|
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
72
79
|
end
|
|
73
80
|
|
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.13.
|
|
4
|
+
version: 0.13.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/phlex/reactive/mcp/tools/find_tool.rb
|
|
181
181
|
- lib/phlex/reactive/param_schema.rb
|
|
182
182
|
- lib/phlex/reactive/pending.rb
|
|
183
|
+
- lib/phlex/reactive/pending/markup.rb
|
|
183
184
|
- lib/phlex/reactive/reply.rb
|
|
184
185
|
- lib/phlex/reactive/response.rb
|
|
185
186
|
- lib/phlex/reactive/settle.rb
|