phlex-reactive 0.10.0 → 0.11.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 +246 -0
- data/README.md +127 -78
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -8
- data/app/javascript/phlex/reactive/confirm_predicate.js +52 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm_predicate.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +370 -179
- 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 +122 -34
- data/lib/phlex/reactive/component/helpers.rb +307 -165
- data/lib/phlex/reactive/component/registry.rb +6 -1
- data/lib/phlex/reactive/component.rb +3 -3
- data/lib/phlex/reactive/defer.rb +1 -1
- data/lib/phlex/reactive/deferred_render_job.rb +2 -2
- data/lib/phlex/reactive/engine.rb +14 -0
- data/lib/phlex/reactive/js.rb +29 -15
- data/lib/phlex/reactive/reply.rb +105 -44
- data/lib/phlex/reactive/response.rb +139 -48
- data/lib/phlex/reactive/streamable.rb +229 -199
- data/lib/phlex/reactive/test_helpers.rb +1 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +137 -12
- metadata +4 -1
|
@@ -10,21 +10,56 @@ module Phlex
|
|
|
10
10
|
# A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go
|
|
11
11
|
# through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
13
|
+
# An action builds one through `reply` (issue #182 — the ONE door; the former
|
|
14
|
+
# Response.* class verbs are removed and raise a guided rewrite):
|
|
15
|
+
#
|
|
16
|
+
# reply.replace # re-render in place (the default, explicit)
|
|
17
|
+
# reply.replace.flash(:error, msg) # surface a validation error
|
|
18
|
+
# reply.replace.also(heading: @record.name) # + a companion element by id
|
|
19
|
+
# reply.remove # drop the element (e.g. moderation queue)
|
|
20
|
+
# reply.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
21
|
+
# reply.replace.stream(Totals.update(@order)) # multi-stream
|
|
19
22
|
class Response
|
|
20
23
|
attr_reader :streams, :redirect_url, :token_component, :subject_component
|
|
21
24
|
|
|
25
|
+
# Issue #182: `reply` is the ONE documented door. Each former public class
|
|
26
|
+
# verb (Response.replace(self), …) is removed — it raises a guided
|
|
27
|
+
# ArgumentError naming the `reply.<verb>` rewrite. The builder BODIES live
|
|
28
|
+
# on as internal-use `build_*` class methods (public in Ruby terms — Reply
|
|
29
|
+
# calls them directly — but NOT part of the documented reply-facing surface;
|
|
30
|
+
# Response stays the single place that knows how to construct itself). The
|
|
31
|
+
# rewrite shown for a collection verb points at the keyword form (issue #182).
|
|
32
|
+
REMOVED_CLASS_VERBS = {
|
|
33
|
+
replace: "reply.replace",
|
|
34
|
+
morph: "reply.morph",
|
|
35
|
+
update: "reply.update",
|
|
36
|
+
remove: "reply.remove",
|
|
37
|
+
redirect: "reply.redirect(url)",
|
|
38
|
+
with: "reply.with(*streams)",
|
|
39
|
+
streams: "reply.streams(*streams)",
|
|
40
|
+
collection_append: "reply.append(model, to: :name)",
|
|
41
|
+
collection_prepend: "reply.prepend(model, to: :name)",
|
|
42
|
+
collection_remove: "reply.remove(model, from: :name)"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# rubocop:disable Style/ItBlockParameter -- `it` is illegal inside the
|
|
46
|
+
# define_singleton_method block (it takes ordinary |*, **| params), so the
|
|
47
|
+
# outer each_key must name its param explicitly.
|
|
48
|
+
REMOVED_CLASS_VERBS.each_key do |verb|
|
|
49
|
+
define_singleton_method(verb) do |*, **|
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Phlex::Reactive::Response.#{verb} was removed in issue #182 — " \
|
|
52
|
+
"return #{REMOVED_CLASS_VERBS[verb]} from the action (reply is the only door)"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
# rubocop:enable Style/ItBlockParameter
|
|
56
|
+
|
|
22
57
|
class << self
|
|
23
58
|
# Re-render the component in place (explicit form of today's default).
|
|
24
59
|
# `morph: true` morphs the subtree (preserves the focused input + caret)
|
|
25
|
-
# instead of an outerHTML swap — see .
|
|
26
|
-
def
|
|
27
|
-
new(streams: [
|
|
60
|
+
# instead of an outerHTML swap — see .build_morph (issue #28).
|
|
61
|
+
def build_replace(component, morph: false)
|
|
62
|
+
new(streams: [component.to_stream_replace(morph:)],
|
|
28
63
|
subject_component: component)
|
|
29
64
|
end
|
|
30
65
|
|
|
@@ -34,27 +69,27 @@ module Phlex
|
|
|
34
69
|
# per-field reactive editing (a "spreadsheet" grid where a debounced save
|
|
35
70
|
# fires while the user is still typing/tabbing). The morphed root still
|
|
36
71
|
# carries the fresh signed token, so the next action verifies.
|
|
37
|
-
def
|
|
72
|
+
def build_morph(component) = new(streams: [component.to_stream_replace(morph: true)], subject_component: component)
|
|
38
73
|
|
|
39
74
|
# Update only inner HTML (preserves the root element + its token attr).
|
|
40
75
|
# `morph: true` morphs the inner HTML in place (issue #113) instead of
|
|
41
76
|
# replacing it, so a cross-tab update keeps a peer's focus/caret.
|
|
42
|
-
def
|
|
77
|
+
def build_update(component, morph: false)
|
|
43
78
|
new(streams: [component.to_stream_update(morph:)], subject_component: component)
|
|
44
79
|
end
|
|
45
80
|
|
|
46
81
|
# Remove the component's element from the DOM. Uses the instance
|
|
47
82
|
# to_stream_remove (the component already knows its own #id — no
|
|
48
83
|
# class-builder reconstruction; works for record- and state-backed).
|
|
49
|
-
def
|
|
84
|
+
def build_remove(component) = new(streams: [component.to_stream_remove], render_self: false)
|
|
50
85
|
|
|
51
86
|
# Client-side full navigation (Turbo.visit). Use when the current URL
|
|
52
87
|
# is dead (slug rename) or the outcome belongs on another page. Pass a
|
|
53
88
|
# *_url (the off-request render context has no request host for *_path).
|
|
54
|
-
def
|
|
89
|
+
def build_redirect(url) = new(redirect_url: url, render_self: false)
|
|
55
90
|
|
|
56
91
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
57
|
-
def
|
|
92
|
+
def build_with(*strings) = new(streams: strings.flatten)
|
|
58
93
|
|
|
59
94
|
# --- Reactive collections (issue #35) ---
|
|
60
95
|
# Add/remove a row in a declared reactive_collection, emitting the row
|
|
@@ -75,19 +110,19 @@ module Phlex
|
|
|
75
110
|
# container owns the add/remove trigger, so the endpoint appends its inert
|
|
76
111
|
# `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
|
|
77
112
|
# the token forward without re-rendering the rows.
|
|
78
|
-
def
|
|
113
|
+
def build_collection_append(component, name, model, **row_kwargs)
|
|
79
114
|
definition = collection_def!(component, name)
|
|
80
|
-
new(streams: collection_add_streams(definition, component, model, :append),
|
|
115
|
+
new(streams: collection_add_streams(definition, component, model, :append, row_kwargs),
|
|
81
116
|
render_self: false, token_component: component)
|
|
82
117
|
end
|
|
83
118
|
|
|
84
|
-
def
|
|
119
|
+
def build_collection_prepend(component, name, model, **row_kwargs)
|
|
85
120
|
definition = collection_def!(component, name)
|
|
86
|
-
new(streams: collection_add_streams(definition, component, model, :prepend),
|
|
121
|
+
new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs),
|
|
87
122
|
render_self: false, token_component: component)
|
|
88
123
|
end
|
|
89
124
|
|
|
90
|
-
def
|
|
125
|
+
def build_collection_remove(component, name, model)
|
|
91
126
|
definition = collection_def!(component, name)
|
|
92
127
|
new(streams: collection_remove_streams(definition, component, model),
|
|
93
128
|
render_self: false, token_component: component)
|
|
@@ -99,15 +134,17 @@ module Phlex
|
|
|
99
134
|
# for an undeclared name (a typo'd collection should fail loudly, not
|
|
100
135
|
# silently emit an empty Response).
|
|
101
136
|
def collection_def!(component, name)
|
|
102
|
-
component.class.
|
|
137
|
+
component.class.reactive_collections[name.to_sym] ||
|
|
103
138
|
raise(Phlex::Reactive::Error, "undeclared reactive_collection :#{name} on #{component.class}")
|
|
104
139
|
end
|
|
105
140
|
|
|
106
141
|
# Row add (append/prepend) + count + empty-state clear. The empty-state is
|
|
107
142
|
# removed only when the list just crossed 0->1 (size == 1) — appending to
|
|
108
143
|
# an already-populated list leaves it untouched.
|
|
109
|
-
def collection_add_streams(definition, component, model, action)
|
|
110
|
-
|
|
144
|
+
def collection_add_streams(definition, component, model, action, row_kwargs = {})
|
|
145
|
+
# row_kwargs (issue #186) thread to the row component's init via the class
|
|
146
|
+
# stream builder's **options passthrough (ItemRow.new(model:, **row_kwargs)).
|
|
147
|
+
streams = [definition.item.public_send(action, target: definition.container, model:, **row_kwargs)]
|
|
111
148
|
append_count_stream(streams, definition, component)
|
|
112
149
|
|
|
113
150
|
size = definition.size_for(component)
|
|
@@ -162,11 +199,11 @@ module Phlex
|
|
|
162
199
|
# spreadsheet-like grid where a debounced save re-streams only a total
|
|
163
200
|
# cell and the user is still typing in a sibling field.
|
|
164
201
|
#
|
|
165
|
-
#
|
|
202
|
+
# reply.streams(Totals.update(@invoice)) # update only the totals
|
|
166
203
|
#
|
|
167
204
|
# render_self is false (we do NOT inject the full replace); the token is
|
|
168
205
|
# refreshed by the bound component's token stream instead.
|
|
169
|
-
def
|
|
206
|
+
def build_streams(component, *strings)
|
|
170
207
|
new(streams: strings.flatten, render_self: false, token_component: component)
|
|
171
208
|
end
|
|
172
209
|
|
|
@@ -194,9 +231,11 @@ module Phlex
|
|
|
194
231
|
# so dismiss_after has nowhere to hang — it wraps only string content.
|
|
195
232
|
return render_html(content) if content.is_a?(::Phlex::SGML)
|
|
196
233
|
|
|
197
|
-
|
|
198
|
-
if
|
|
199
|
-
|
|
234
|
+
builder = Phlex::Reactive.flash_component
|
|
235
|
+
if builder
|
|
236
|
+
# The app-owned callable maps (level, content) to its own component —
|
|
237
|
+
# the gem no longer guesses kwargs (issue #182).
|
|
238
|
+
html = render_html(builder.call(level, content))
|
|
200
239
|
return dismiss_after ? wrap_dismiss(html, dismiss_after) : html
|
|
201
240
|
end
|
|
202
241
|
|
|
@@ -225,6 +264,13 @@ module Phlex
|
|
|
225
264
|
data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}</div>).html_safe
|
|
226
265
|
end
|
|
227
266
|
|
|
267
|
+
# Issue #182: flash rendering is an INTERNAL concern — the endpoint's
|
|
268
|
+
# error_flash path and Response#flash reach these via send. They are not
|
|
269
|
+
# part of the public reply surface. (Inside `class << self`, plain `private`
|
|
270
|
+
# marks these singleton methods private — private_class_method is for the
|
|
271
|
+
# class body, not here.)
|
|
272
|
+
private :flash_stream, :flash_html, :default_flash_html
|
|
273
|
+
|
|
228
274
|
# The self-dismiss attribute, or "" when off. The value is forced through
|
|
229
275
|
# to_i so a String/float (or an injection attempt) becomes a plain integer
|
|
230
276
|
# — the client's document-level handler reads it as a timeout in ms.
|
|
@@ -242,7 +288,7 @@ data-reactive-flash-level="#{level_attr}"#{dismiss_attr(dismiss_after)}>#{body}<
|
|
|
242
288
|
end
|
|
243
289
|
|
|
244
290
|
# Build a turbo-stream that updates an arbitrary target id with `content`
|
|
245
|
-
# (a Phlex component instance or an HTML string). Used by #
|
|
291
|
+
# (a Phlex component instance or an HTML string). Used by #also to
|
|
246
292
|
# re-render a companion element that isn't itself a Streamable component.
|
|
247
293
|
def update_stream(target, content)
|
|
248
294
|
Phlex::Reactive.stream_builder.update(target, html: render_html(content))
|
|
@@ -405,30 +451,67 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
405
451
|
# container, so it works for reply AND broadcast flashes (issue #100). It
|
|
406
452
|
# wraps only STRING content; a verbatim Phlex component owns its lifecycle.
|
|
407
453
|
def flash(level, content, target: Phlex::Reactive.flash_target, dismiss_after: nil)
|
|
408
|
-
stream(self.class.flash_stream
|
|
454
|
+
stream(self.class.send(:flash_stream, level, content, target:, dismiss_after:))
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Also re-render COMPANION elements alongside self (issue #182 — one door,
|
|
458
|
+
# replacing also_update + also_replace). The ARGUMENT TYPE picks the Turbo
|
|
459
|
+
# action; there is no verb to choose:
|
|
460
|
+
#
|
|
461
|
+
# .also(SummaryCard.new(order: @order)) -> REPLACE at the component's own #id
|
|
462
|
+
# .also(SummaryCard.new(order: @order), morph:) -> the same, MORPHED in place
|
|
463
|
+
# .also(page_heading: @record.name) -> UPDATE (inner HTML) of id "page_heading"
|
|
464
|
+
# .also("nav-badge" => @record.name) -> UPDATE of id "nav-badge" (dashed id)
|
|
465
|
+
#
|
|
466
|
+
# So the two shapes are:
|
|
467
|
+
#
|
|
468
|
+
# * a Streamable COMPONENT (positional) — a `replace` targeting its OWN #id
|
|
469
|
+
# (it self-targets, like every reactive component). `morph: true` morphs
|
|
470
|
+
# it in place (issue #28) when it holds focusable inputs to preserve.
|
|
471
|
+
# * target => content pairs — an `update` (inner HTML) of each id. The id
|
|
472
|
+
# need NOT be a reactive component (it's any element), so the only sound
|
|
473
|
+
# swap is its inner HTML. Content is a plain String (HTML-ESCAPED by
|
|
474
|
+
# Turbo, so a model value is safe), a Phlex component (rendered +
|
|
475
|
+
# auto-escaped), or an html_safe String for intentional raw HTML. Written
|
|
476
|
+
# brace-less as keywords or as an explicit Hash.
|
|
477
|
+
#
|
|
478
|
+
# This is NOT for collection rows — a row add/remove goes through
|
|
479
|
+
# reply.append(model, to: :name) / reply.remove(id, from: :name), which also
|
|
480
|
+
# emit the count + empty-state streams. `.also` only re-renders EXISTING
|
|
481
|
+
# companion elements.
|
|
482
|
+
#
|
|
483
|
+
# Returns a NEW Response (immutable). `companion` is EITHER a positional
|
|
484
|
+
# component OR target=>content pairs — never both (that raises). The
|
|
485
|
+
# brace-less keyword form lands in **targets; an explicit Hash lands in
|
|
486
|
+
# `companion`. `morph:` is reserved for the component form.
|
|
487
|
+
def also(companion = :__none, morph: false, **targets)
|
|
488
|
+
if companion != :__none && !targets.empty?
|
|
489
|
+
raise ArgumentError, "reply.also takes EITHER a component OR target => content pairs, not both"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
case companion
|
|
493
|
+
when :__none
|
|
494
|
+
also_targets(targets)
|
|
495
|
+
when ::Phlex::SGML
|
|
496
|
+
stream(companion.to_stream_replace(morph:))
|
|
497
|
+
when Hash
|
|
498
|
+
also_targets(companion)
|
|
499
|
+
else
|
|
500
|
+
raise ArgumentError,
|
|
501
|
+
"reply.also expects target => content pairs or a Streamable component, got #{companion.class}"
|
|
502
|
+
end
|
|
409
503
|
end
|
|
410
504
|
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
# * a Phlex component — rendered + auto-escaped through the renderer (use
|
|
417
|
-
# this when the companion has its own markup), or an `html_safe` String
|
|
418
|
-
# for intentional raw HTML.
|
|
419
|
-
# Returns a NEW Response (immutable). The common "re-render self + N
|
|
420
|
-
# siblings" case no longer needs raw turbo_stream_builder.
|
|
421
|
-
def also_update(target, html:)
|
|
422
|
-
stream(self.class.update_stream(target, html))
|
|
505
|
+
# Issue #182: also_update / also_replace collapsed into #also. Guided errors
|
|
506
|
+
# naming the rewrite (the html: kwarg lied — it accepted components too).
|
|
507
|
+
def also_update(target, **)
|
|
508
|
+
raise ArgumentError,
|
|
509
|
+
"also_update was removed in issue #182 — use also(#{target.inspect} => content)"
|
|
423
510
|
end
|
|
424
511
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
# `morph: true` morphs the companion in place (issue #28) — use it when the
|
|
429
|
-
# companion also holds focusable inputs that must survive the re-render.
|
|
430
|
-
def also_replace(component, morph: false)
|
|
431
|
-
stream(morph ? component.to_stream_morph : component.to_stream_replace)
|
|
512
|
+
def also_replace(*, **)
|
|
513
|
+
raise ArgumentError,
|
|
514
|
+
"also_replace was removed in issue #182 — use also(component, morph: …)"
|
|
432
515
|
end
|
|
433
516
|
|
|
434
517
|
def redirect? = !@redirect_url.nil?
|
|
@@ -448,6 +531,14 @@ data-reactive-ops="#{ERB::Util.html_escape(json)}"></turbo-stream>).html_safe
|
|
|
448
531
|
def default_js_target
|
|
449
532
|
(@subject_component || @token_component)&.id
|
|
450
533
|
end
|
|
534
|
+
|
|
535
|
+
# Build one update stream per { target_id => content } pair (issue #182). An
|
|
536
|
+
# empty call is a dead reply.also — fail loudly rather than return unchanged.
|
|
537
|
+
def also_targets(targets)
|
|
538
|
+
raise ArgumentError, "reply.also needs target => content pairs (or a component) — got nothing" if targets.empty?
|
|
539
|
+
|
|
540
|
+
stream(*targets.map { |target, content| self.class.update_stream(target, content) })
|
|
541
|
+
end
|
|
451
542
|
end
|
|
452
543
|
end
|
|
453
544
|
end
|