phlex-reactive 0.2.8 → 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 +71 -0
- data/README.md +147 -28
- data/app/controllers/phlex/reactive/actions_controller.rb +24 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +86 -5
- data/lib/phlex/reactive/component.rb +84 -2
- data/lib/phlex/reactive/engine.rb +9 -0
- data/lib/phlex/reactive/reply.rb +75 -0
- data/lib/phlex/reactive/response.rb +89 -6
- data/lib/phlex/reactive/streamable.rb +54 -4
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +54 -0
- 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,77 @@ 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`.
|
|
52
|
+
- **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
|
|
53
|
+
…)` and `reactive_select(:status, …) { … }` render a form control already bound
|
|
54
|
+
to an action param — no hand-written `name: "value"` magic string to forget
|
|
55
|
+
(which silently leaves the action with no params). `reactive_field(:param,
|
|
56
|
+
**attrs)` returns just the attribute hash to spread onto any control; an
|
|
57
|
+
explicit `name:` still wins as an escape hatch. The trigger stays on the
|
|
58
|
+
button, so focusing the field doesn't dispatch and collapse edit mode.
|
|
59
|
+
- **`accepts_nested_attributes_for` helper (issue #24).** `nested_update!(:address,
|
|
60
|
+
address)` maps a declared nested param straight onto `<assoc>_attributes` and
|
|
61
|
+
carries the existing associated record's id, so `update_only:` matches it in
|
|
62
|
+
place instead of building a second `has_one` — replacing the per-editor
|
|
63
|
+
`*_attributes` + id-preservation boilerplate that was easy to get subtly wrong.
|
|
64
|
+
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
65
|
+
updating, for combining with other attributes.
|
|
66
|
+
- **`Response#also_update` / `#also_replace` (issue #25).** Re-render a companion
|
|
67
|
+
element alongside self without dropping to raw `turbo_stream_builder`:
|
|
68
|
+
`Response.replace(self).also_update("page_heading", html: @record.name)` adds an
|
|
69
|
+
update stream for an arbitrary DOM id (`html` is a string or a Phlex component,
|
|
70
|
+
rendered through the configured renderer), and `.also_replace(other_component)`
|
|
71
|
+
re-renders another Streamable component targeting its own `#id`. Both are
|
|
72
|
+
immutable and additive, so the self-replace still refreshes the signed token.
|
|
73
|
+
- **Boot-time integration guards (issue #26).** Two silent first-run failures now
|
|
74
|
+
surface a clear warning. A host catch-all route (`match "*path", …`) that
|
|
75
|
+
shadows the engine's appended `POST /reactive/actions` (every reactive POST
|
|
76
|
+
404s) is detected at boot via a route-recognition check
|
|
77
|
+
(`Phlex::Reactive.action_route_ok?`), logging how to exempt the path. And when
|
|
78
|
+
`data-controller="reactive"` elements are on the page but no controller
|
|
79
|
+
connected — the `lazyLoadControllersFrom` case where the gem's controller was
|
|
80
|
+
never registered — the client runtime logs a console warning naming the fix.
|
|
81
|
+
The README gains an "Integration troubleshooting" section covering both.
|
|
11
82
|
- **Nested & array param types (issue #16).** Action param schemas can now
|
|
12
83
|
declare arrays and nested hashes, not just scalars: wrap a type in an array
|
|
13
84
|
(`bank_account_ids: [:integer]`) for an array param, or wrap a hash schema in
|
data/README.md
CHANGED
|
@@ -124,7 +124,7 @@ Import and register it from your controllers entrypoint:
|
|
|
124
124
|
|
|
125
125
|
```js
|
|
126
126
|
import { application } from "./application"
|
|
127
|
-
import ReactiveController from "phlex
|
|
127
|
+
import ReactiveController from "phlex/reactive/reactive_controller"
|
|
128
128
|
application.register("reactive", ReactiveController)
|
|
129
129
|
```
|
|
130
130
|
|
|
@@ -137,6 +137,47 @@ gem; point your bundler at the gem path or copy it in. See
|
|
|
137
137
|
and a Phlex `ApplicationComponent` base class. pgbus is optional but recommended
|
|
138
138
|
for broadcasting.
|
|
139
139
|
|
|
140
|
+
### Integration troubleshooting (silent "nothing happens")
|
|
141
|
+
|
|
142
|
+
Two host-app setups make the first reactive component *silently do nothing* —
|
|
143
|
+
components render, but no action ever fires, with no error pointing at the cause.
|
|
144
|
+
The gem now logs a warning for each, but here are the fixes:
|
|
145
|
+
|
|
146
|
+
**A catch-all route shadows `POST /reactive/actions`.** The engine appends its
|
|
147
|
+
route *after* everything in your `config/routes.rb`, so a bottom-of-file
|
|
148
|
+
catch-all wins and every reactive POST 404s:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# config/routes.rb — a catch-all like this shadows the engine's appended route
|
|
152
|
+
match "*path", to: "errors#not_found", via: :all
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Exempt the reactive path from the catch-all (or set
|
|
156
|
+
`Phlex::Reactive.action_path` to an unshadowed path):
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
match "*path", to: "errors#not_found", via: :all,
|
|
160
|
+
constraints: ->(req) { !req.path.start_with?("/reactive/") }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
At boot the gem warns (`[phlex-reactive] POST /reactive/actions does not resolve
|
|
164
|
+
to phlex/reactive/actions …`) when the route is shadowed.
|
|
165
|
+
|
|
166
|
+
**The `reactive` controller isn't registered (`lazyLoadControllersFrom` apps).**
|
|
167
|
+
`lazyLoadControllersFrom("controllers", application)` only registers controllers
|
|
168
|
+
under `app/javascript/controllers/`. The gem's controller lives outside that dir,
|
|
169
|
+
so `data-controller="reactive"` does nothing until you register it explicitly:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// app/javascript/controllers/index.js (or your Stimulus entrypoint)
|
|
173
|
+
import ReactiveController from "phlex/reactive/reactive_controller"
|
|
174
|
+
application.register("reactive", ReactiveController)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
If reactive elements are on the page but the controller never connected, the
|
|
178
|
+
runtime logs a console warning (`[phlex-reactive] found N element(s) with
|
|
179
|
+
data-controller="reactive" but the reactive controller never connected …`).
|
|
180
|
+
|
|
140
181
|
---
|
|
141
182
|
|
|
142
183
|
## The mental model in one picture
|
|
@@ -150,8 +191,8 @@ for broadcasting.
|
|
|
150
191
|
│ rebuild component (record from DB)
|
|
151
192
|
│ run the whitelisted action
|
|
152
193
|
│ re-render → <turbo-stream replace id> (default; an action
|
|
153
|
-
│ may return
|
|
154
|
-
└──────── Turbo
|
|
194
|
+
│ may return reply.<verb> — see "Controlling the action's reply")
|
|
195
|
+
└──────── Turbo applies it in ◀──────────────────────────────┘
|
|
155
196
|
|
|
156
197
|
...and for OTHER tabs/users:
|
|
157
198
|
model change → Component.broadcast_replace_to(stream) → pgbus SSE → same morph
|
|
@@ -251,11 +292,11 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
|
251
292
|
| Method | Use |
|
|
252
293
|
|---|---|
|
|
253
294
|
| `#id` (you implement) | Stable DOM id == Turbo Stream target. Must match the root element's `id`. |
|
|
254
|
-
| `.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"` |
|
|
255
296
|
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
256
|
-
| `.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 |
|
|
257
298
|
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
|
|
258
|
-
| `#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 |
|
|
259
300
|
|
|
260
301
|
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
261
302
|
|
|
@@ -269,6 +310,10 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
269
310
|
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
270
311
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
271
312
|
| `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
|
|
313
|
+
| `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
|
|
314
|
+
| `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
|
|
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). |
|
|
272
317
|
|
|
273
318
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
274
319
|
in the schema is dropped before reaching your method.
|
|
@@ -337,40 +382,114 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
|
|
|
337
382
|
button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
338
383
|
```
|
|
339
384
|
|
|
340
|
-
|
|
385
|
+
**Binding inputs to action params (drop the magic `name:`).** A field's value
|
|
386
|
+
travels with an action only if its `name` equals the param. Hand-writing
|
|
387
|
+
`name: "value"` on every input is easy to forget — the action then silently gets
|
|
388
|
+
nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
|
|
389
|
+
stays on the button, so focusing the field doesn't dispatch and collapse edit
|
|
390
|
+
mode):
|
|
341
391
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
|
|
345
|
-
Returning anything else keeps the default, so existing actions are unaffected.
|
|
392
|
+
```ruby
|
|
393
|
+
action :save, params: { value: :string, status: :string }
|
|
346
394
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
395
|
+
def view_template
|
|
396
|
+
span(id:, **reactive_attrs) do
|
|
397
|
+
reactive_input(:value, value: @record.name) # <input name="value" …>
|
|
398
|
+
reactive_select(:status) do # <select name="status">…</select>
|
|
399
|
+
%w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
|
|
400
|
+
end
|
|
401
|
+
button(**mix(on(:save), data: { testid: "save" })) { "Save" }
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
`reactive_field(:value, **attrs)` returns just the attribute hash if you'd rather
|
|
407
|
+
spread it onto a control yourself. An explicit `name:` still wins (escape hatch).
|
|
408
|
+
|
|
409
|
+
**Editing an associated record (`accepts_nested_attributes_for`).** `nested_update!`
|
|
410
|
+
maps a declared nested param straight onto `<assoc>_attributes` and carries the
|
|
411
|
+
existing record's id, so `update_only:` matches it in place instead of building a
|
|
412
|
+
second `has_one` (the boilerplate that's easy to get subtly wrong):
|
|
350
413
|
|
|
351
414
|
```ruby
|
|
352
|
-
|
|
415
|
+
# Account has_one :address; accepts_nested_attributes_for :address, update_only: true
|
|
416
|
+
action :save, params: { address: { street: :string, city: :string } }
|
|
417
|
+
|
|
418
|
+
def save(address:)
|
|
419
|
+
nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
424
|
+
updating, if you need to combine it with other attributes.
|
|
425
|
+
|
|
426
|
+
### `reply` — controlling the action's reply
|
|
427
|
+
|
|
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.
|
|
433
|
+
|
|
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):
|
|
353
437
|
|
|
438
|
+
```ruby
|
|
354
439
|
def rename(title:)
|
|
355
|
-
return
|
|
356
|
-
|
|
440
|
+
return reply.replace.flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
|
|
441
|
+
reply.replace
|
|
357
442
|
end
|
|
358
443
|
|
|
359
|
-
def approve = (@row.approve!;
|
|
360
|
-
def publish = (@article.publish!;
|
|
361
|
-
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)
|
|
453
|
+
|
|
454
|
+
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
|
|
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)))
|
|
362
462
|
```
|
|
363
463
|
|
|
364
464
|
| Builder | Reply |
|
|
365
465
|
|---|---|
|
|
366
|
-
| `
|
|
367
|
-
|
|
|
368
|
-
|
|
|
369
|
-
|
|
|
370
|
-
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
token
|
|
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) |
|
|
468
|
+
| `.also_update(target, html:)` | also re-render a companion element by DOM id; `html` is a plain string (escaped) or a Phlex component |
|
|
469
|
+
| `.also_replace(component, morph: false)` | also re-render another Streamable component, targeting its own `#id`; `morph: true` morphs it in place |
|
|
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"`) |
|
|
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) |
|
|
475
|
+
|
|
476
|
+
`.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
|
|
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
|
|
488
|
+
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
489
|
+
> `html: @account.name` is safe even for user-supplied values. To emit intentional
|
|
490
|
+
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
|
|
491
|
+
> rendered and auto-escaped through the renderer — or an `html_safe` string for
|
|
492
|
+
> raw HTML you control.
|
|
374
493
|
|
|
375
494
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
376
495
|
|
|
@@ -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,81 @@ 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 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Registration guard (issue #26 part 2) -------------------------------
|
|
70
|
+
// In a `lazyLoadControllersFrom("controllers", application)` app, only
|
|
71
|
+
// controllers under app/javascript/controllers/ are registered. This module
|
|
72
|
+
// lives outside that dir, so importing it isn't enough — `data-controller=
|
|
73
|
+
// "reactive"` does NOTHING until the host runs application.register("reactive",
|
|
74
|
+
// ...). The failure is silent: components render, but no action ever fires.
|
|
75
|
+
//
|
|
76
|
+
// We can't warn from connect() in that case (connect never runs). Instead, once
|
|
77
|
+
// the page is ready, if reactive elements exist but no controller has connected,
|
|
78
|
+
// the controller wasn't registered — so we warn, pointing at the fix.
|
|
79
|
+
let reactiveConnected = false
|
|
80
|
+
|
|
81
|
+
export function checkReactiveRegistration() {
|
|
82
|
+
if (reactiveConnected) return
|
|
83
|
+
if (typeof document === "undefined") return
|
|
84
|
+
const els = document.querySelectorAll('[data-controller~="reactive"]')
|
|
85
|
+
if (!els || els.length === 0) return
|
|
86
|
+
console.warn(
|
|
87
|
+
"[phlex-reactive] found " + els.length + ' element(s) with data-controller="reactive" ' +
|
|
88
|
+
"but the reactive controller never connected. It is loaded but not registered — " +
|
|
89
|
+
'add `application.register("reactive", ReactiveController)` (importmap) or import it ' +
|
|
90
|
+
"into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README."
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Test seams (no-ops in production usage).
|
|
95
|
+
export function __resetReactiveRegistrationForTest() {
|
|
96
|
+
reactiveConnected = false
|
|
97
|
+
}
|
|
98
|
+
export function __markReactiveConnectedForTest() {
|
|
99
|
+
reactiveConnected = true
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
103
|
+
// Defer past initial controller connection (a microtask/tick after ready).
|
|
104
|
+
const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)
|
|
105
|
+
if (document.readyState === "loading") {
|
|
106
|
+
document.addEventListener("DOMContentLoaded", scheduleCheck, { once: true })
|
|
107
|
+
} else {
|
|
108
|
+
scheduleCheck()
|
|
109
|
+
}
|
|
37
110
|
}
|
|
38
111
|
|
|
39
112
|
// Register this controller eagerly (not lazily) so a click immediately after
|
|
@@ -47,6 +120,12 @@ export default class extends Controller {
|
|
|
47
120
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
48
121
|
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
49
122
|
|
|
123
|
+
// Mark that a reactive controller actually connected, so the registration
|
|
124
|
+
// guard above knows the controller was registered (issue #26 part 2).
|
|
125
|
+
connect() {
|
|
126
|
+
reactiveConnected = true
|
|
127
|
+
}
|
|
128
|
+
|
|
50
129
|
// Tear down any pending debounce timers when the controller leaves the DOM
|
|
51
130
|
// (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
|
|
52
131
|
// fired yet would later call #enqueue on a disconnected controller — a round
|
|
@@ -173,8 +252,10 @@ export default class extends Controller {
|
|
|
173
252
|
// Capture the new token from the response synchronously, so the next
|
|
174
253
|
// queued request uses it without waiting for the async DOM morph.
|
|
175
254
|
this.#currentToken = this.#extractToken(html) ?? this.#currentToken
|
|
176
|
-
// Turbo applies the <turbo-stream> ops
|
|
177
|
-
// 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.
|
|
178
259
|
window.Turbo.renderStreamMessage(html)
|
|
179
260
|
} catch (error) {
|
|
180
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) { ... }
|
|
@@ -206,8 +224,72 @@ module Phlex
|
|
|
206
224
|
attrs
|
|
207
225
|
end
|
|
208
226
|
|
|
227
|
+
# Bind a form control's `name` to an action param so its value travels with
|
|
228
|
+
# the action — instead of hand-writing the magic `name: "value"` on every
|
|
229
|
+
# input and silently getting no params when you forget it (issue #23).
|
|
230
|
+
# Returns a Phlex attributes hash to spread onto any control:
|
|
231
|
+
# input(**reactive_field(:value, value: @record.name))
|
|
232
|
+
# select(**reactive_field(:status)) { ... }
|
|
233
|
+
# Extra attrs merge over the binding; an explicit name: still wins (escape
|
|
234
|
+
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
235
|
+
# focusing the input doesn't dispatch and collapse edit mode.
|
|
236
|
+
def reactive_field(param, **attrs)
|
|
237
|
+
{name: param.to_s, **attrs}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
241
|
+
# input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
|
|
242
|
+
# reactive_input(:value, value: @record.name, type: "text")
|
|
243
|
+
def reactive_input(param, **attrs)
|
|
244
|
+
input(**reactive_field(param, **attrs))
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Render a <select> bound to an action param (issue #23). The options block
|
|
248
|
+
# is the element's content, so the awkward FormBuilder positional split
|
|
249
|
+
# (where name: lands after the options/html-options args) goes away:
|
|
250
|
+
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
251
|
+
def reactive_select(param, **attrs, &block)
|
|
252
|
+
select(**reactive_field(param, **attrs), &block)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
256
|
+
# existing associated record's id so accepts_nested_attributes_for matches
|
|
257
|
+
# it IN PLACE instead of building a second one (issue #24). Returns the
|
|
258
|
+
# update hash; pass it to update!:
|
|
259
|
+
# def save(address:) = nested_update!(:address, address)
|
|
260
|
+
# The id is only added when the association already exists, so the first
|
|
261
|
+
# save (no associated record yet) creates one cleanly. The given attrs are
|
|
262
|
+
# not mutated.
|
|
263
|
+
def nested_attributes(association, attrs)
|
|
264
|
+
merged = attrs.dup
|
|
265
|
+
existing = reactive_record_for_nested.public_send(association)
|
|
266
|
+
merged[:id] = existing.id if existing
|
|
267
|
+
|
|
268
|
+
{"#{association}_attributes": merged}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
272
|
+
# apply it to the component's record in one call (issue #24). Extra keyword
|
|
273
|
+
# attributes update alongside the association.
|
|
274
|
+
# def save(address:, name:) = nested_update!(:address, address, name:)
|
|
275
|
+
def nested_update!(association, attrs, **extra)
|
|
276
|
+
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
277
|
+
end
|
|
278
|
+
|
|
209
279
|
private
|
|
210
280
|
|
|
281
|
+
# The component's record, for the nested-attributes helpers. Requires a
|
|
282
|
+
# declared reactive_record (the nested helper only makes sense for a
|
|
283
|
+
# record-backed component).
|
|
284
|
+
def reactive_record_for_nested
|
|
285
|
+
key = self.class.reactive_record_key
|
|
286
|
+
unless key
|
|
287
|
+
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
instance_variable_get(:"@#{key}")
|
|
291
|
+
end
|
|
292
|
+
|
|
211
293
|
# Signed identity payload: the class name plus whichever identity pieces
|
|
212
294
|
# the component declares — a record GlobalID (`gid`), signed state (`s`),
|
|
213
295
|
# or both. Keeping them in ONE MessageVerifier payload makes the state
|
|
@@ -40,6 +40,15 @@ module Phlex
|
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
# Boot-time guard (issue #26): warn if the action path doesn't resolve to
|
|
45
|
+
# the gem controller. Runs after_initialize so the host's full route set
|
|
46
|
+
# (including a bottom-of-file catch-all that would shadow our appended
|
|
47
|
+
# route) is drawn. Turns the opaque "every reactive POST 404s" failure into
|
|
48
|
+
# a one-line log pointing at the cause. No-op when the route is fine.
|
|
49
|
+
config.after_initialize do
|
|
50
|
+
Phlex::Reactive.warn_unless_action_route_mounted!
|
|
51
|
+
end
|
|
43
52
|
end
|
|
44
53
|
end
|
|
45
54
|
end
|
|
@@ -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
|
|
@@ -12,15 +12,28 @@ module Phlex
|
|
|
12
12
|
#
|
|
13
13
|
# Response.replace(self) # re-render in place (the default, explicit)
|
|
14
14
|
# Response.replace(self).flash(:error, msg) # surface a validation error
|
|
15
|
+
# Response.replace(self).also_update("heading", html: @record.name) # + a companion element
|
|
15
16
|
# Response.remove(self) # drop the element (e.g. moderation queue)
|
|
16
17
|
# Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
17
18
|
# Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
18
19
|
class Response
|
|
19
|
-
attr_reader :streams, :redirect_url
|
|
20
|
+
attr_reader :streams, :redirect_url, :token_component
|
|
20
21
|
|
|
21
22
|
class << self
|
|
22
23
|
# Re-render the component in place (explicit form of today's default).
|
|
23
|
-
|
|
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])
|
|
24
37
|
|
|
25
38
|
# Morph only inner HTML (preserves the root element + its token attr).
|
|
26
39
|
def update(component) = new(streams: [component.to_stream_update])
|
|
@@ -38,14 +51,49 @@ module Phlex
|
|
|
38
51
|
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
39
52
|
def with(*strings) = new(streams: strings.flatten)
|
|
40
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
|
+
|
|
41
70
|
# Build a flash turbo-stream that appends `content` into a host-app
|
|
42
71
|
# container. `content` is a Phlex component instance (rendered through
|
|
43
72
|
# the configured renderer so t()/url_for work) or a ready HTML string —
|
|
44
73
|
# supplied by the caller because the render context is off-request
|
|
45
74
|
# (there is no Rails `flash`).
|
|
46
75
|
def flash_stream(_level, content, target:)
|
|
47
|
-
|
|
48
|
-
|
|
76
|
+
Phlex::Reactive.flash_builder.append(target, html: render_html(content))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build a turbo-stream that updates an arbitrary target id with `content`
|
|
80
|
+
# (a Phlex component instance or an HTML string). Used by #also_update to
|
|
81
|
+
# re-render a companion element that isn't itself a Streamable component.
|
|
82
|
+
def update_stream(target, content)
|
|
83
|
+
Phlex::Reactive.flash_builder.update(target, html: render_html(content))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Resolve `content` to the HTML for a turbo-stream's `html:`. Two forms,
|
|
87
|
+
# both SAFE against injection by default:
|
|
88
|
+
# * a Phlex component instance — rendered through the configured
|
|
89
|
+
# renderer, which auto-escapes interpolated values.
|
|
90
|
+
# * any other value — coerced with to_s and handed to Turbo's
|
|
91
|
+
# TagBuilder, which HTML-ESCAPES a plain String. So a model value
|
|
92
|
+
# (`html: @record.name`) cannot inject markup. To emit intentional
|
|
93
|
+
# raw HTML, pass an `html_safe` String (Turbo leaves those verbatim)
|
|
94
|
+
# or a Phlex component. Same contract as the pre-existing flash_stream.
|
|
95
|
+
def render_html(content)
|
|
96
|
+
content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
|
|
49
97
|
end
|
|
50
98
|
end
|
|
51
99
|
|
|
@@ -53,10 +101,16 @@ module Phlex
|
|
|
53
101
|
# GUARANTEES the component's own replace is present so its
|
|
54
102
|
# data-reactive-token-value refreshes (the client extracts the next token
|
|
55
103
|
# from the response HTML). remove/redirect set it false (nothing stays).
|
|
56
|
-
|
|
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)
|
|
57
110
|
@streams = streams.freeze
|
|
58
111
|
@redirect_url = redirect_url
|
|
59
112
|
@render_self = render_self
|
|
113
|
+
@token_component = token_component
|
|
60
114
|
freeze
|
|
61
115
|
end
|
|
62
116
|
|
|
@@ -66,7 +120,8 @@ module Phlex
|
|
|
66
120
|
self.class.new(
|
|
67
121
|
streams: @streams + more.flatten,
|
|
68
122
|
redirect_url: @redirect_url,
|
|
69
|
-
render_self: @render_self
|
|
123
|
+
render_self: @render_self,
|
|
124
|
+
token_component: @token_component
|
|
70
125
|
)
|
|
71
126
|
end
|
|
72
127
|
|
|
@@ -76,8 +131,36 @@ module Phlex
|
|
|
76
131
|
stream(self.class.flash_stream(level, content, target:))
|
|
77
132
|
end
|
|
78
133
|
|
|
134
|
+
# Also re-render a COMPANION element alongside self — a page heading, a
|
|
135
|
+
# summary card, a badge that recomputes from the saved value (issue #25).
|
|
136
|
+
# `target` is the sibling element's DOM id. `html` is either:
|
|
137
|
+
# * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
|
|
138
|
+
# Response.replace(self).also_update("page_heading", html: @record.name)
|
|
139
|
+
# * a Phlex component — rendered + auto-escaped through the renderer (use
|
|
140
|
+
# this when the companion has its own markup), or an `html_safe` String
|
|
141
|
+
# for intentional raw HTML.
|
|
142
|
+
# Returns a NEW Response (immutable). The common "re-render self + N
|
|
143
|
+
# siblings" case no longer needs raw turbo_stream_builder.
|
|
144
|
+
def also_update(target, html:)
|
|
145
|
+
stream(self.class.update_stream(target, html))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Like #also_update, but renders ANOTHER Streamable component and replaces
|
|
149
|
+
# it by its own #id — for a companion that is itself a component.
|
|
150
|
+
# Response.replace(self).also_replace(SummaryCard.new(order: @order))
|
|
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)
|
|
155
|
+
end
|
|
156
|
+
|
|
79
157
|
def redirect? = !@redirect_url.nil?
|
|
80
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?
|
|
81
164
|
end
|
|
82
165
|
end
|
|
83
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.
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -133,8 +133,62 @@ module Phlex
|
|
|
133
133
|
Thread.current[:phlex_reactive_connection_id] = previous
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# The controller a correctly-mounted action path resolves to. Used by the
|
|
137
|
+
# route guard below.
|
|
138
|
+
ACTIONS_CONTROLLER = "phlex/reactive/actions"
|
|
139
|
+
|
|
140
|
+
# True when a POST to `path` resolves to the gem's ActionsController. A host
|
|
141
|
+
# catch-all route (match "*path", ...) appended above the engine's route
|
|
142
|
+
# SHADOWS it, so every reactive POST 404s and none of the controller runs —
|
|
143
|
+
# the opaque "is the endpoint even mounted?" failure (issue #26). A false
|
|
144
|
+
# here is the signal. Returns false (not raise) when nothing matches.
|
|
145
|
+
def action_route_ok?(path = action_path)
|
|
146
|
+
return false unless defined?(::Rails) && ::Rails.application
|
|
147
|
+
|
|
148
|
+
# At after_initialize (when the boot guard runs) the host's routes may not
|
|
149
|
+
# be drawn yet, so recognize_path would see an incomplete set and report a
|
|
150
|
+
# false shadow. Force-load routes first (idempotent — no-op if already
|
|
151
|
+
# loaded), so the check is correct whether it runs at boot or at runtime.
|
|
152
|
+
ensure_routes_loaded
|
|
153
|
+
recognized = ::Rails.application.routes.recognize_path(path, method: :post)
|
|
154
|
+
recognized[:controller] == ACTIONS_CONTROLLER
|
|
155
|
+
rescue ActionController::RoutingError, ActiveRecord::RecordNotFound
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Log a clear warning (once, at boot) when the action path doesn't resolve
|
|
160
|
+
# to the gem controller — pointing at the catch-all shadow rather than
|
|
161
|
+
# leaving an adopter to guess. Called from the engine's after_initialize.
|
|
162
|
+
def warn_unless_action_route_mounted!(path: action_path, logger: default_logger)
|
|
163
|
+
return if action_route_ok?(path)
|
|
164
|
+
return unless logger
|
|
165
|
+
|
|
166
|
+
logger.warn(
|
|
167
|
+
"[phlex-reactive] POST #{path} does not resolve to #{ACTIONS_CONTROLLER}. " \
|
|
168
|
+
"A host catch-all route (e.g. match \"*path\", ...) likely shadows it, so reactive " \
|
|
169
|
+
"actions will 404. Exempt #{path.sub(%r{\A/}, "")} from the catch-all, or set " \
|
|
170
|
+
"Phlex::Reactive.action_path to an unshadowed path. See the README integration section."
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
136
174
|
private
|
|
137
175
|
|
|
176
|
+
# Materialize the route set if it hasn't been drawn yet (the engine appends
|
|
177
|
+
# POST /reactive/actions when routes are drawn, which may be after the boot
|
|
178
|
+
# guard's after_initialize). Idempotent; tolerant of older Rails.
|
|
179
|
+
def ensure_routes_loaded
|
|
180
|
+
reloader = ::Rails.application.routes_reloader
|
|
181
|
+
if reloader.respond_to?(:execute_unless_loaded)
|
|
182
|
+
reloader.execute_unless_loaded
|
|
183
|
+
elsif ::Rails.application.respond_to?(:reload_routes_unless_loaded)
|
|
184
|
+
::Rails.application.reload_routes_unless_loaded
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def default_logger
|
|
189
|
+
::Rails.logger if defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
190
|
+
end
|
|
191
|
+
|
|
138
192
|
def default_verifier
|
|
139
193
|
unless defined?(::Rails) && ::Rails.application
|
|
140
194
|
raise Error, "Phlex::Reactive.verifier is unset and Rails.application is unavailable; " \
|
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
|