phlex-reactive 0.2.9 → 0.3.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 +41 -0
- data/README.md +51 -28
- data/app/controllers/phlex/reactive/actions_controller.rb +24 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +37 -5
- data/lib/phlex/reactive/component.rb +20 -2
- data/lib/phlex/reactive/reply.rb +75 -0
- data/lib/phlex/reactive/response.rb +48 -6
- data/lib/phlex/reactive/streamable.rb +54 -4
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7232501319d17c45c8907195117f5af59d8b7a4ff9c8d5efb2c0c8248350041e
|
|
4
|
+
data.tar.gz: 783cd0758a5a51cead67f6f1ea2c8bf23245f62e49a38ea0831328b68b9d0b2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a673a7253c99e7b6fccb5228014a47d2918cda2995de79afdb6545adb89a8af8ab99812569455bc7f206c12d7c7efb710ff90d628d767da012ba5deb690e5ab7
|
|
7
|
+
data.tar.gz: cf68ac7ff60925c73aa0de543665b60d85d8254bada63a40c1786a6a3e7ee3bfceaad123c91daf2f7fec18de8aab04ab61d2ee165b5e0ad241b451ce60c24769
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,47 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **`reply.streams` — partial update with a token-only refresh (issue #30).**
|
|
12
|
+
`reply.streams(Totals.update(@item))` emits **exactly** the streams you pass —
|
|
13
|
+
no forced full-self replace — so an action can re-render only part of a
|
|
14
|
+
component (one total cell, one sub-region) while the rest of the DOM, including
|
|
15
|
+
a sibling `<input>` the user is mid-typing in, is left untouched. The signed
|
|
16
|
+
identity token still rolls forward: the endpoint appends a tiny
|
|
17
|
+
`<turbo-stream action="reactive:token">` (new `Streamable#to_stream_token`) that
|
|
18
|
+
carries the fresh `data-reactive-token-value` and is applied by an inert client
|
|
19
|
+
action — a pure attribute write on the root, so the focused field + caret
|
|
20
|
+
survive. This unblocks spreadsheet-like per-field grid editing that the old
|
|
21
|
+
"every reply re-renders the whole component" behavior made unusable (you had to
|
|
22
|
+
reach for `data-turbo-permanent`). `Response.streams(self, *)` is the underlying
|
|
23
|
+
builder; `reply.streams(*)` is the bound form. Backed by a new `render_self:
|
|
24
|
+
false` + `token_component` path in the endpoint — the legacy full-self-replace
|
|
25
|
+
refresh (`reply.replace` / `reply.with`) is unchanged.
|
|
26
|
+
|
|
27
|
+
- **`reply` — the action-reply builder.** Control an action's reply with
|
|
28
|
+
`reply.replace` / `reply.morph` / `reply.update` / `reply.remove` /
|
|
29
|
+
`reply.redirect(url)` / `reply.with(*)`, chaining `.flash` / `.stream` /
|
|
30
|
+
`.also_update` / `.also_replace` as before. `reply` is a subject-bound facade on
|
|
31
|
+
the component, so the two warts of the old form disappear: no `self` to thread
|
|
32
|
+
(`reply.morph`, not `Response.morph(self)`) and no constant to qualify — a
|
|
33
|
+
namespaced component no longer needs a per-file `Response = …` alias, because
|
|
34
|
+
`reply` is a method resolved on the component, not a lexical constant. It
|
|
35
|
+
returns the same immutable value object the endpoint reads, so the return-value
|
|
36
|
+
contract, immutability, and chaining are unchanged. `Phlex::Reactive::Response`
|
|
37
|
+
remains fully functional but is now an internal detail — `reply` is the
|
|
38
|
+
documented surface.
|
|
39
|
+
|
|
40
|
+
- **Morph response — focus-preserving re-render (issue #28).**
|
|
41
|
+
`reply.morph` (and the opt-in `reply.replace(morph: true)`)
|
|
42
|
+
re-renders a component in place via Turbo 8's bundled Idiomorph
|
|
43
|
+
(`<turbo-stream action="replace" method="morph">`) instead of an outerHTML
|
|
44
|
+
swap. The focused `<input>` and its caret survive the save, making per-field
|
|
45
|
+
reactive editing — a "spreadsheet" grid where a debounced save fires while the
|
|
46
|
+
user is still typing/tabbing — actually viable. Backed by the new
|
|
47
|
+
`Streamable#to_stream_morph` primitive and a `morph:` flag on the
|
|
48
|
+
`.replace` / `.broadcast_replace_to` class builders and `#also_replace`
|
|
49
|
+
(live cross-tab updates can morph too). The default everywhere stays the plain
|
|
50
|
+
replace (no `method="morph"` attribute), so existing components are unchanged.
|
|
51
|
+
No new dependency — Idiomorph ships with `turbo-rails >= 2.0`.
|
|
11
52
|
- **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
|
|
12
53
|
…)` and `reactive_select(:status, …) { … }` render a form control already bound
|
|
13
54
|
to an action param — no hand-written `name: "value"` magic string to forget
|
data/README.md
CHANGED
|
@@ -191,8 +191,8 @@ data-controller="reactive" but the reactive controller never connected …`).
|
|
|
191
191
|
│ rebuild component (record from DB)
|
|
192
192
|
│ run the whitelisted action
|
|
193
193
|
│ re-render → <turbo-stream replace id> (default; an action
|
|
194
|
-
│ may return
|
|
195
|
-
└──────── Turbo
|
|
194
|
+
│ may return reply.<verb> — see "Controlling the action's reply")
|
|
195
|
+
└──────── Turbo applies it in ◀──────────────────────────────┘
|
|
196
196
|
|
|
197
197
|
...and for OTHER tabs/users:
|
|
198
198
|
model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
|
|
@@ -292,11 +292,11 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
|
292
292
|
| Method | Use |
|
|
293
293
|
|---|---|
|
|
294
294
|
| `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
|
|
295
|
-
| `.replace(model = nil, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component |
|
|
295
|
+
| `.replace(model = nil, morph: false, **opts)` | `<turbo-stream action=replace target=id>` of a freshly built component; `morph: true` adds `method="morph"` |
|
|
296
296
|
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
297
|
-
| `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
|
|
297
|
+
| `.broadcast_replace_to(*streamables, model:, morph: false)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable); `morph: true` morphs in place |
|
|
298
298
|
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
|
|
299
|
-
| `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `
|
|
299
|
+
| `#to_stream_replace` / `#to_stream_morph` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `reply`); `#to_stream_morph` morphs in place |
|
|
300
300
|
|
|
301
301
|
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
302
302
|
|
|
@@ -313,6 +313,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
313
313
|
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
|
|
314
314
|
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
315
315
|
| `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
|
|
316
|
+
| `reply.replace` / `.morph` / `.update` / `.remove` / `.redirect(url)` / `.with(*)` | Return from an action to control the reply (flash, remove, redirect, multi-stream). See [Controlling the action's reply](#reply--controlling-the-actions-reply). |
|
|
316
317
|
|
|
317
318
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
318
319
|
in the schema is dropped before reaching your method.
|
|
@@ -422,46 +423,68 @@ end
|
|
|
422
423
|
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
423
424
|
updating, if you need to combine it with other attributes.
|
|
424
425
|
|
|
425
|
-
### `
|
|
426
|
+
### `reply` — controlling the action's reply
|
|
426
427
|
|
|
427
|
-
By default an action re-renders its component in place. **
|
|
428
|
-
`
|
|
429
|
-
cross-tab updates still use
|
|
430
|
-
Returning anything else
|
|
428
|
+
By default an action re-renders its component in place. To do more, **return**
|
|
429
|
+
`reply.<verb>` — a subject-bound builder available in every component. It governs
|
|
430
|
+
only the actor's HTTP reply (cross-tab updates still use
|
|
431
|
+
`broadcast_*_to(..., exclude: reactive_connection_id)`). Returning anything else
|
|
432
|
+
keeps the default, so existing actions are unaffected.
|
|
431
433
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
434
|
+
`reply` reads cleanly: the component is the implicit subject (no `self` to
|
|
435
|
+
thread) and there's no constant to qualify (it's a method, so a namespaced
|
|
436
|
+
component needs no alias):
|
|
435
437
|
|
|
436
438
|
```ruby
|
|
437
|
-
Response = Phlex::Reactive::Response # or qualify each call below
|
|
438
|
-
|
|
439
439
|
def rename(title:)
|
|
440
|
-
return
|
|
441
|
-
|
|
440
|
+
return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
|
|
441
|
+
reply.replace
|
|
442
442
|
end
|
|
443
443
|
|
|
444
|
-
def approve = (@row.approve!;
|
|
445
|
-
def publish = (@article.publish!;
|
|
446
|
-
def add(item:) =
|
|
444
|
+
def approve = (@row.approve!; reply.remove) # drop the element
|
|
445
|
+
def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed → Turbo.visit
|
|
446
|
+
def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
|
|
447
|
+
|
|
448
|
+
# Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
|
|
449
|
+
# while the user is still typing/tabbing. Morph in place so the focused <input>
|
|
450
|
+
# and its in-progress value survive the re-render (issue #28). Note the action is
|
|
451
|
+
# named `update`, yet `reply.morph` is unambiguous — the verb is on `reply`:
|
|
452
|
+
def update(name:) = (@row.update!(name:); reply.morph)
|
|
447
453
|
|
|
448
454
|
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
|
|
449
|
-
def rename(value:) = (@account.update!(name: value);
|
|
455
|
+
def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
|
|
456
|
+
|
|
457
|
+
# Update ONLY part of the component (issue #30): re-stream just the total cell,
|
|
458
|
+
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
|
|
459
|
+
# token-only refresh — no full-self replace — so a sibling <input> the user is
|
|
460
|
+
# mid-typing in is never torn down. The signed token still rolls forward.
|
|
461
|
+
def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
|
|
450
462
|
```
|
|
451
463
|
|
|
452
464
|
| Builder | Reply |
|
|
453
465
|
|---|---|
|
|
454
|
-
| `
|
|
466
|
+
| `reply.replace` / `reply.update` | re-render in place (default; `replace` is an outerHTML swap, `update` morphs inner HTML) |
|
|
467
|
+
| `reply.morph` / `reply.replace(morph: true)` | re-render in place via Idiomorph (`method="morph"`) — preserves the focused `<input>` + caret; for per-field reactive editing (issue #28) |
|
|
455
468
|
| `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
|
|
456
|
-
| `.also_replace(component)` | also re-render another Streamable component, targeting its own `#id` |
|
|
469
|
+
| `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
|
|
457
470
|
| `.flash(level, content, target: …)` | append a flash; `content` is a plain string (escaped) or a Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
|
|
458
|
-
| `
|
|
459
|
-
| `
|
|
460
|
-
| `
|
|
471
|
+
| `reply.remove` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
472
|
+
| `reply.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
473
|
+
| `reply.streams(*streams)` | **partial update** — emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
|
|
474
|
+
| `reply.with(*streams)` / `#stream(*more)` | multi-stream (self re-render still injected for the token) |
|
|
461
475
|
|
|
462
476
|
`.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
|
|
463
|
-
signed token always refreshes.
|
|
464
|
-
|
|
477
|
+
signed token always refreshes. **`reply.streams`** is the exception that proves
|
|
478
|
+
the rule: it deliberately skips the full-self replace (so your hand-built streams
|
|
479
|
+
update only the targets you name) and refreshes the token via a tiny inert
|
|
480
|
+
`reactive:token` stream instead — the token rolls forward without re-rendering
|
|
481
|
+
(and clobbering) the component's live inputs.
|
|
482
|
+
|
|
483
|
+
> **Under the hood.** `reply.<verb>` returns a `Phlex::Reactive::Response` — the
|
|
484
|
+
> immutable value object the endpoint reads. You can build one directly
|
|
485
|
+
> (`Phlex::Reactive::Response.replace(self)`) and it still works, but `reply` is
|
|
486
|
+
> the preferred surface; treat `Response` as an internal detail.
|
|
487
|
+
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
465
488
|
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
466
489
|
> `html: @account.name` is safe even for user-supplied values. To emit intentional
|
|
467
490
|
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
|
|
@@ -78,6 +78,19 @@ module Phlex
|
|
|
78
78
|
return [redirect_stream(result.redirect_url)] if result.redirect?
|
|
79
79
|
|
|
80
80
|
streams = result.streams
|
|
81
|
+
|
|
82
|
+
# Partial update (Response.streams / reply.streams, issue #30): the action
|
|
83
|
+
# re-rendered only PART of the component and opted out of the full-self
|
|
84
|
+
# replace. Append a tiny token-only stream so the signed token still rolls
|
|
85
|
+
# forward WITHOUT re-rendering (and clobbering) the live inputs. Skip it
|
|
86
|
+
# only if the caller already supplied THIS component's token (idempotent) —
|
|
87
|
+
# the dedupe is scoped to the actor's own target, NOT a global substring,
|
|
88
|
+
# so a partial reply that legitimately includes another reactive
|
|
89
|
+
# component's stream (which carries its OWN token) still refreshes ours.
|
|
90
|
+
if result.refresh_token? && !carries_token_for?(streams, result.token_component)
|
|
91
|
+
return [*streams, result.token_component.to_stream_token]
|
|
92
|
+
end
|
|
93
|
+
|
|
81
94
|
# Guarantee the component's signed identity token is refreshed unless the
|
|
82
95
|
# Response opted out (remove/redirect navigate away — handled above). The
|
|
83
96
|
# client reads the next token from the response body (#extractToken), so
|
|
@@ -94,6 +107,17 @@ module Phlex
|
|
|
94
107
|
streams
|
|
95
108
|
end
|
|
96
109
|
|
|
110
|
+
# True when one of `streams` already carries a fresh token TARGETING this
|
|
111
|
+
# component — i.e. the caller hand-built the actor's own token-bearing
|
|
112
|
+
# stream, so appending to_stream_token would double it. Scoped to the
|
|
113
|
+
# component's target id (not a global substring) so a sibling component's
|
|
114
|
+
# stream, which carries its OWN token for a DIFFERENT target, doesn't fool
|
|
115
|
+
# us into skipping this component's refresh.
|
|
116
|
+
def carries_token_for?(streams, component)
|
|
117
|
+
target = %(target="#{ERB::Util.html_escape(component.id)}")
|
|
118
|
+
streams.any? { |s| s.include?("data-reactive-token-value") && s.include?(target) }
|
|
119
|
+
end
|
|
120
|
+
|
|
97
121
|
# A 200 turbo-stream carrying a namespaced custom action the client turns
|
|
98
122
|
# into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
|
|
99
123
|
# (response.redirected). The matching client handler is registered in
|
|
@@ -4,7 +4,8 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
4
4
|
// replaces the per-feature Stimulus controllers you'd otherwise hand-write
|
|
5
5
|
// for interactive components. A component declares its actions in Ruby (via
|
|
6
6
|
// Phlex::Reactive::Component); this controller binds DOM events to a single
|
|
7
|
-
// HTTP round trip and lets Turbo
|
|
7
|
+
// HTTP round trip and lets Turbo apply the re-rendered component back in
|
|
8
|
+
// (replace by default; method="morph" — Response.morph — preserves focus).
|
|
8
9
|
//
|
|
9
10
|
// Wire format (client -> server), POST <action path>, turbo-stream Accept:
|
|
10
11
|
// { token: "<signed identity>", act: "<action>", params: {...} }
|
|
@@ -31,9 +32,38 @@ export function registerReactiveVisit() {
|
|
|
31
32
|
if (url) window.Turbo.visit(url, { action: "advance" })
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
// Custom turbo-stream action: a TOKEN-ONLY refresh (issue #30). A partial
|
|
37
|
+
// update (Response.streams / reply.streams) re-renders only PART of a component
|
|
38
|
+
// — so there's no full-self replace to carry the next signed token. The server
|
|
39
|
+
// instead emits `<turbo-stream action="reactive:token" target="<id>"
|
|
40
|
+
// data-reactive-token-value="<fresh>">`. #perform's #extractToken already reads
|
|
41
|
+
// the token out of the response body for the NEXT queued request; this handler
|
|
42
|
+
// keeps the DOM in sync too, writing the attribute onto the root element so the
|
|
43
|
+
// `tokenValue` fallback stays fresh. It's a pure attribute set — no node is
|
|
44
|
+
// replaced — so a focused <input> + caret survive (the whole point: update a
|
|
45
|
+
// total cell without tearing down the field the user is typing in).
|
|
46
|
+
export function registerReactiveToken() {
|
|
47
|
+
const actions = window.Turbo?.StreamActions
|
|
48
|
+
if (!actions || actions["reactive:token"]) return
|
|
49
|
+
actions["reactive:token"] = function () {
|
|
50
|
+
const token = this.getAttribute("data-reactive-token-value")
|
|
51
|
+
const target = this.getAttribute("target")
|
|
52
|
+
if (!token || !target) return
|
|
53
|
+
const el = document.getElementById(target)
|
|
54
|
+
// Stimulus reads the token via the `token` value -> data-reactive-token-value.
|
|
55
|
+
if (el) el.setAttribute("data-reactive-token-value", token)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerReactiveActions() {
|
|
60
|
+
registerReactiveVisit()
|
|
61
|
+
registerReactiveToken()
|
|
62
|
+
}
|
|
63
|
+
|
|
34
64
|
if (typeof window !== "undefined") {
|
|
35
|
-
if (window.Turbo)
|
|
36
|
-
else document.addEventListener("turbo:load",
|
|
65
|
+
if (window.Turbo) registerReactiveActions()
|
|
66
|
+
else document.addEventListener("turbo:load", registerReactiveActions, { once: true })
|
|
37
67
|
}
|
|
38
68
|
|
|
39
69
|
// --- Registration guard (issue #26 part 2) -------------------------------
|
|
@@ -222,8 +252,10 @@ export default class extends Controller {
|
|
|
222
252
|
// Capture the new token from the response synchronously, so the next
|
|
223
253
|
// queued request uses it without waiting for the async DOM morph.
|
|
224
254
|
this.#currentToken = this.#extractToken(html) ?? this.#currentToken
|
|
225
|
-
// Turbo applies the <turbo-stream> ops
|
|
226
|
-
// focus
|
|
255
|
+
// Turbo applies the <turbo-stream> ops by id. A plain replace is an
|
|
256
|
+
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
257
|
+
// replace (Response.morph) or an update morphs in place, preserving the
|
|
258
|
+
// focused input + caret on unchanged nodes — see issue #28.
|
|
227
259
|
window.Turbo.renderStreamMessage(html)
|
|
228
260
|
} catch (error) {
|
|
229
261
|
console.error("[phlex-reactive] action error", error)
|
|
@@ -5,8 +5,10 @@ module Phlex
|
|
|
5
5
|
# Component turns a self-contained Phlex component into a Livewire-style
|
|
6
6
|
# reactive unit: declare actions in Ruby, and the generic `reactive`
|
|
7
7
|
# Stimulus controller wires clicks/inputs to an HTTP round trip that
|
|
8
|
-
# re-renders the component and
|
|
9
|
-
#
|
|
8
|
+
# re-renders the component and applies it back into the DOM (a plain replace
|
|
9
|
+
# by default; return Response.morph(self) to morph in place and keep the
|
|
10
|
+
# focused input — issue #28). No per-feature Stimulus controllers, no
|
|
11
|
+
# hand-picked Turbo targets.
|
|
10
12
|
#
|
|
11
13
|
# Include alongside Phlex::Reactive::Streamable (which provides #id and the
|
|
12
14
|
# re-render machinery).
|
|
@@ -167,6 +169,22 @@ module Phlex
|
|
|
167
169
|
Phlex::Reactive.current_connection_id
|
|
168
170
|
end
|
|
169
171
|
|
|
172
|
+
# Subject-bound reply builder — the preferred way to control an action's
|
|
173
|
+
# reply. `reply.replace.flash(:error, msg)` reads cleaner than
|
|
174
|
+
# `Phlex::Reactive::Response.replace(self).flash(:error, msg)`: the
|
|
175
|
+
# component is the implicit subject (no `self` to thread) and there's no
|
|
176
|
+
# constant to qualify (reply is a method, so a namespaced component needs
|
|
177
|
+
# no `Response = …` alias). It returns the same immutable Response the
|
|
178
|
+
# endpoint reads, so chaining and the legacy return-value contract are
|
|
179
|
+
# unchanged. See Phlex::Reactive::Reply.
|
|
180
|
+
#
|
|
181
|
+
# def archive = reply.remove
|
|
182
|
+
# def go_home = reply.redirect("/todos")
|
|
183
|
+
# def update(name:) = (@account.update!(name:); reply.morph)
|
|
184
|
+
def reply
|
|
185
|
+
Phlex::Reactive::Reply.new(self)
|
|
186
|
+
end
|
|
187
|
+
|
|
170
188
|
# Root-element attributes: marks the element reactive and carries the
|
|
171
189
|
# signed identity token. Spread onto the root:
|
|
172
190
|
# div(id:, **reactive_attrs) { ... }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# A component-bound facade over Response — the surface an action body uses to
|
|
6
|
+
# control its reply. Component#reply returns one, so an action writes
|
|
7
|
+
#
|
|
8
|
+
# reply.replace.flash(:error, msg)
|
|
9
|
+
#
|
|
10
|
+
# instead of
|
|
11
|
+
#
|
|
12
|
+
# Phlex::Reactive::Response.replace(self).flash(:error, msg)
|
|
13
|
+
#
|
|
14
|
+
# Two warts disappear: there is no constant to qualify (reply is a method,
|
|
15
|
+
# resolved on the component — so a namespaced component needs no
|
|
16
|
+
# `Response = …` alias) and no `self` to thread (the component is bound).
|
|
17
|
+
#
|
|
18
|
+
# Reply is NOT a Response and does NOT subclass one: each verb forwards to
|
|
19
|
+
# the Response class method, supplying the bound component as the subject, and
|
|
20
|
+
# returns the real, frozen Response value the endpoint already honors. So
|
|
21
|
+
# immutability, chaining (.flash/.stream/.also_update/.also_replace), and
|
|
22
|
+
# render_self? are inherited untouched — and there is no "verb after a chained
|
|
23
|
+
# Response" asymmetry, because the chain is plain Response all the way down.
|
|
24
|
+
#
|
|
25
|
+
# Response remains the public value object (it's what the endpoint reads); it
|
|
26
|
+
# is simply an internal detail you rarely name directly now.
|
|
27
|
+
class Reply
|
|
28
|
+
def initialize(component)
|
|
29
|
+
@component = component
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Self-targeting builders — the bound component is supplied implicitly.
|
|
33
|
+
|
|
34
|
+
# Re-render in place. `morph: true` morphs the subtree (preserves the
|
|
35
|
+
# focused input + caret) instead of an outerHTML swap — see #morph.
|
|
36
|
+
def replace(morph: false)
|
|
37
|
+
Response.replace(@component, morph:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
|
|
41
|
+
def morph
|
|
42
|
+
Response.morph(@component)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Morph only inner HTML (preserves the root element + its token attr).
|
|
46
|
+
def update
|
|
47
|
+
Response.update(@component)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Remove the component's element from the DOM (render_self false).
|
|
51
|
+
def remove
|
|
52
|
+
Response.remove(@component)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Subject-free builders — pass straight through so they read naturally.
|
|
56
|
+
|
|
57
|
+
# Client-side full navigation (Turbo.visit). Pass a *_url.
|
|
58
|
+
def redirect(url)
|
|
59
|
+
Response.redirect(url)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
63
|
+
def with(*strings)
|
|
64
|
+
Response.with(*strings)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Self-targeting again: emit exactly these streams with a TOKEN-ONLY refresh
|
|
68
|
+
# (issue #30) — partial/per-field update, NO full-self replace, so the
|
|
69
|
+
# component's live inputs survive. The bound component supplies the token.
|
|
70
|
+
def streams(*strings)
|
|
71
|
+
Response.streams(@component, *strings)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -17,11 +17,23 @@ module Phlex
|
|
|
17
17
|
# Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
18
18
|
# Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
19
19
|
class Response
|
|
20
|
-
attr_reader :streams, :redirect_url
|
|
20
|
+
attr_reader :streams, :redirect_url, :token_component
|
|
21
21
|
|
|
22
22
|
class << self
|
|
23
23
|
# Re-render the component in place (explicit form of today's default).
|
|
24
|
-
|
|
24
|
+
# `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])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Re-render the component in place via Idiomorph (issue #28). Emits
|
|
31
|
+
# `<turbo-stream action="replace" method="morph">`, so Turbo 8 morphs the
|
|
32
|
+
# subtree — the focused <input> + caret survive the save. Use this for
|
|
33
|
+
# per-field reactive editing (a "spreadsheet" grid where a debounced save
|
|
34
|
+
# fires while the user is still typing/tabbing). The morphed root still
|
|
35
|
+
# carries the fresh signed token, so the next action verifies.
|
|
36
|
+
def morph(component) = new(streams: [component.to_stream_morph])
|
|
25
37
|
|
|
26
38
|
# Morph only inner HTML (preserves the root element + its token attr).
|
|
27
39
|
def update(component) = new(streams: [component.to_stream_update])
|
|
@@ -39,6 +51,22 @@ module Phlex
|
|
|
39
51
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
40
52
|
def with(*strings) = new(streams: strings.flatten)
|
|
41
53
|
|
|
54
|
+
# Partial / per-field update with a TOKEN-ONLY refresh (issue #30). Emits
|
|
55
|
+
# EXACTLY the given streams — no forced full-self replace — but binds
|
|
56
|
+
# `component` so the endpoint appends its tiny `to_stream_token` stream.
|
|
57
|
+
# So the signed token rolls forward (the next action verifies) while the
|
|
58
|
+
# component's own live inputs are never torn down: ideal for a
|
|
59
|
+
# spreadsheet-like grid where a debounced save re-streams only a total
|
|
60
|
+
# cell and the user is still typing in a sibling field.
|
|
61
|
+
#
|
|
62
|
+
# Response.streams(self, Totals.update(@invoice)) # update only the totals
|
|
63
|
+
#
|
|
64
|
+
# render_self is false (we do NOT inject the full replace); the token is
|
|
65
|
+
# refreshed by the bound component's token stream instead.
|
|
66
|
+
def streams(component, *strings)
|
|
67
|
+
new(streams: strings.flatten, render_self: false, token_component: component)
|
|
68
|
+
end
|
|
69
|
+
|
|
42
70
|
# Build a flash turbo-stream that appends `content` into a host-app
|
|
43
71
|
# container. `content` is a Phlex component instance (rendered through
|
|
44
72
|
# the configured renderer so t()/url_for work) or a ready HTML string —
|
|
@@ -73,10 +101,16 @@ module Phlex
|
|
|
73
101
|
# GUARANTEES the component's own replace is present so its
|
|
74
102
|
# data-reactive-token-value refreshes (the client extracts the next token
|
|
75
103
|
# from the response HTML). remove/redirect set it false (nothing stays).
|
|
76
|
-
|
|
104
|
+
#
|
|
105
|
+
# token_component: set by .streams (issue #30) — a partial update that opts
|
|
106
|
+
# OUT of the full-self replace but still needs the token refreshed. The
|
|
107
|
+
# endpoint appends this component's tiny to_stream_token instead, so the
|
|
108
|
+
# token rolls forward without re-rendering (and clobbering) the children.
|
|
109
|
+
def initialize(streams: [], redirect_url: nil, render_self: true, token_component: nil)
|
|
77
110
|
@streams = streams.freeze
|
|
78
111
|
@redirect_url = redirect_url
|
|
79
112
|
@render_self = render_self
|
|
113
|
+
@token_component = token_component
|
|
80
114
|
freeze
|
|
81
115
|
end
|
|
82
116
|
|
|
@@ -86,7 +120,8 @@ module Phlex
|
|
|
86
120
|
self.class.new(
|
|
87
121
|
streams: @streams + more.flatten,
|
|
88
122
|
redirect_url: @redirect_url,
|
|
89
|
-
render_self: @render_self
|
|
123
|
+
render_self: @render_self,
|
|
124
|
+
token_component: @token_component
|
|
90
125
|
)
|
|
91
126
|
end
|
|
92
127
|
|
|
@@ -113,12 +148,19 @@ module Phlex
|
|
|
113
148
|
# Like #also_update, but renders ANOTHER Streamable component and replaces
|
|
114
149
|
# it by its own #id — for a companion that is itself a component.
|
|
115
150
|
# Response.replace(self).also_replace(SummaryCard.new(order: @order))
|
|
116
|
-
|
|
117
|
-
|
|
151
|
+
# `morph: true` morphs the companion in place (issue #28) — use it when the
|
|
152
|
+
# companion also holds focusable inputs that must survive the re-render.
|
|
153
|
+
def also_replace(component, morph: false)
|
|
154
|
+
stream(morph ? component.to_stream_morph : component.to_stream_replace)
|
|
118
155
|
end
|
|
119
156
|
|
|
120
157
|
def redirect? = !@redirect_url.nil?
|
|
121
158
|
def render_self? = @render_self
|
|
159
|
+
|
|
160
|
+
# True when a partial update (.streams) opted out of the full-self replace
|
|
161
|
+
# but still needs the token rolled forward — the endpoint appends the bound
|
|
162
|
+
# component's tiny token-only stream (issue #30).
|
|
163
|
+
def refresh_token? = !@token_component.nil?
|
|
122
164
|
end
|
|
123
165
|
end
|
|
124
166
|
end
|
|
@@ -64,9 +64,13 @@ module Phlex
|
|
|
64
64
|
renderer.render(component, layout: false)
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# `morph: true` emits `<turbo-stream action="replace" method="morph">` so
|
|
68
|
+
# Turbo 8's bundled Idiomorph morphs the subtree in place — preserving a
|
|
69
|
+
# focused <input> + caret — instead of an outerHTML swap (issue #28).
|
|
70
|
+
# Default (morph: false) is the unchanged plain replace.
|
|
71
|
+
def replace(model = nil, morph: false, **options)
|
|
68
72
|
component = build(model, options)
|
|
69
|
-
turbo_stream_builder.replace(component.id, html: render_component(component))
|
|
73
|
+
turbo_stream_builder.replace(component.id, html: render_component(component), **morph_method(morph))
|
|
70
74
|
end
|
|
71
75
|
|
|
72
76
|
def update(model = nil, **options)
|
|
@@ -103,11 +107,15 @@ module Phlex
|
|
|
103
107
|
# connection id — pass it to suppress the actor's own echo (the actor
|
|
104
108
|
# already got the action's HTTP response). With Action Cable these are
|
|
105
109
|
# ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
|
|
106
|
-
|
|
110
|
+
# `morph: true` makes the live cross-tab update morph in place (issue #28),
|
|
111
|
+
# so a peer tab keeps its focus/caret on the morphed row. The broadcast
|
|
112
|
+
# path takes EXTRA <turbo-stream> attributes via `attributes:` (not the
|
|
113
|
+
# TagBuilder's `method:` kwarg), so the morph flag rides there.
|
|
114
|
+
def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, morph: false, **options)
|
|
107
115
|
component = build(model, options)
|
|
108
116
|
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
109
117
|
*streamables, target: component.id, html: render_component(component),
|
|
110
|
-
**broadcast_transport_opts(exclude:, visible_to:)
|
|
118
|
+
**morph_attributes(morph), **broadcast_transport_opts(exclude:, visible_to:)
|
|
111
119
|
)
|
|
112
120
|
end
|
|
113
121
|
|
|
@@ -149,6 +157,20 @@ module Phlex
|
|
|
149
157
|
new(**(model ? component_args(model, options) : options))
|
|
150
158
|
end
|
|
151
159
|
|
|
160
|
+
# The TagBuilder (the .replace class builder + to_stream_morph) takes
|
|
161
|
+
# `method: :morph` to emit the `method="morph"` attribute. Pass it ONLY
|
|
162
|
+
# when morphing, so the default call produces today's plain replace.
|
|
163
|
+
def morph_method(morph)
|
|
164
|
+
morph ? {method: :morph} : {}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The BROADCAST path renders extra <turbo-stream> attributes through
|
|
168
|
+
# `attributes:` (it has no `method:` kwarg — that would fall into the
|
|
169
|
+
# render args and be dropped). Same wire result: method="morph".
|
|
170
|
+
def morph_attributes(morph)
|
|
171
|
+
morph ? {attributes: {method: "morph"}} : {}
|
|
172
|
+
end
|
|
173
|
+
|
|
152
174
|
# Only include transport opts that were actually given, so on Action
|
|
153
175
|
# Cable (which doesn't accept them) the common no-opts call is unchanged.
|
|
154
176
|
def broadcast_transport_opts(exclude:, visible_to:)
|
|
@@ -184,10 +206,38 @@ module Phlex
|
|
|
184
206
|
self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self))
|
|
185
207
|
end
|
|
186
208
|
|
|
209
|
+
# Render THIS instance as a MORPHING replace (issue #28):
|
|
210
|
+
# `<turbo-stream action="replace" method="morph">`. Turbo 8's bundled
|
|
211
|
+
# Idiomorph morphs the subtree in place — preserving the focused <input> +
|
|
212
|
+
# caret across the re-render — while still carrying the root's fresh
|
|
213
|
+
# data-reactive-token-value (so the signed token refreshes). Used by
|
|
214
|
+
# Response.morph / Response.replace(self, morph: true).
|
|
215
|
+
def to_stream_morph
|
|
216
|
+
self.class.turbo_stream_builder.replace(id, html: self.class.render_component(self), method: :morph)
|
|
217
|
+
end
|
|
218
|
+
|
|
187
219
|
def to_stream_update
|
|
188
220
|
self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
|
|
189
221
|
end
|
|
190
222
|
|
|
223
|
+
# Render a TOKEN-ONLY refresh stream (issue #30): a tiny
|
|
224
|
+
# `<turbo-stream action="reactive:token">` carrying the component's fresh
|
|
225
|
+
# signed token, with NO rendered body. It lets an action update only PART
|
|
226
|
+
# of a component (its own hand-built streams) while still rolling the
|
|
227
|
+
# signed identity token forward — the client reads the next token from this
|
|
228
|
+
# attribute (#extractToken) and an inert client action writes it onto the
|
|
229
|
+
# root (a pure attribute set, so a focused <input> + caret survive). Unlike
|
|
230
|
+
# to_stream_replace, it does NOT re-render the children, so a live input
|
|
231
|
+
# the user is typing into is never torn down. Used by Response.streams.
|
|
232
|
+
#
|
|
233
|
+
# The component carries its token via Component#reactive_token; a Streamable
|
|
234
|
+
# that isn't a Component (no token) simply has nothing to refresh — guarded
|
|
235
|
+
# by respond_to? so the primitive stays usable on a bare Streamable.
|
|
236
|
+
def to_stream_token
|
|
237
|
+
token = respond_to?(:reactive_token) ? reactive_token : nil
|
|
238
|
+
%(<turbo-stream action="reactive:token" target="#{ERB::Util.html_escape(id)}" data-reactive-token-value="#{ERB::Util.html_escape(token)}"></turbo-stream>)
|
|
239
|
+
end
|
|
240
|
+
|
|
191
241
|
# Render THIS instance as a remove stream. The component already knows its
|
|
192
242
|
# own #id, so no record/class reconstruction is needed (works for record-
|
|
193
243
|
# and state-backed components alike). Used by Response.remove.
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -129,6 +129,7 @@ files:
|
|
|
129
129
|
- lib/phlex/reactive.rb
|
|
130
130
|
- lib/phlex/reactive/component.rb
|
|
131
131
|
- lib/phlex/reactive/engine.rb
|
|
132
|
+
- lib/phlex/reactive/reply.rb
|
|
132
133
|
- lib/phlex/reactive/response.rb
|
|
133
134
|
- lib/phlex/reactive/streamable.rb
|
|
134
135
|
- lib/phlex/reactive/version.rb
|