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.
@@ -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
- # Response.replace(self) # re-render in place (the default, explicit)
14
- # Response.replace(self).flash(:error, msg) # surface a validation error
15
- # Response.replace(self).also_update("heading", html: @record.name) # + a companion element
16
- # Response.remove(self) # drop the element (e.g. moderation queue)
17
- # Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
18
- # Response.replace(self).stream(Totals.update(@order)) # multi-stream
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 .morph (issue #28).
26
- def replace(component, morph: false)
27
- new(streams: [morph ? component.to_stream_morph : component.to_stream_replace],
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 morph(component) = new(streams: [component.to_stream_morph], subject_component: component)
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 update(component, morph: false)
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 remove(component) = new(streams: [component.to_stream_remove], render_self: false)
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 redirect(url) = new(redirect_url: url, render_self: false)
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 with(*strings) = new(streams: strings.flatten)
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 collection_append(component, name, model)
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 collection_prepend(component, name, model)
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 collection_remove(component, name, model)
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.reactive_collection_def(name) ||
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
- streams = [definition.item.public_send(action, target: definition.container, model:)]
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
- # Response.streams(self, Totals.update(@invoice)) # update only the totals
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 streams(component, *strings)
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
- component_class = Phlex::Reactive.flash_component
198
- if component_class
199
- html = render_html(component_class.new(level:, content:))
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 #also_update to
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(level, content, target:, dismiss_after:))
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
- # Also re-render a COMPANION element alongside self a page heading, a
412
- # summary card, a badge that recomputes from the saved value (issue #25).
413
- # `target` is the sibling element's DOM id. `html` is either:
414
- # * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
415
- # Response.replace(self).also_update("page_heading", html: @record.name)
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
- # Like #also_update, but renders ANOTHER Streamable component and replaces
426
- # it by its own #id — for a companion that is itself a component.
427
- # Response.replace(self).also_replace(SummaryCard.new(order: @order))
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