phlex-reactive 0.2.8 → 0.2.9
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 +30 -0
- data/README.md +100 -4
- data/app/javascript/phlex/reactive/reactive_controller.js +49 -0
- data/lib/phlex/reactive/component.rb +64 -0
- data/lib/phlex/reactive/engine.rb +9 -0
- data/lib/phlex/reactive/response.rb +43 -2
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +54 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6b8ebca93e744e1c2fa3797de877cefc732db8a6cb950d95874c9e00e4dee76
|
|
4
|
+
data.tar.gz: 119b1373f8ade989791845fc9cf3206f7786eb97f6009faaabfadb1485c82b44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee11a3bf0ac506cdbde2e97e9e47bad436a16050c0b150cd5340bbff9ae846586487bbf6a2bbf028ff20c44faa64f2e57953ddf15adcbafc82491d5e15a9f22f
|
|
7
|
+
data.tar.gz: 3bd5cd4a362ed18f59da8bf375474aa8beabd5cfdd0d62572d5fce043318c057b01afcf3942acaec30417dc16fd3b3d8d9e6ef9f7ec524f55cf411cc168b8003
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **Input/select param-binding helpers (issue #23).** `reactive_input(:value,
|
|
12
|
+
…)` and `reactive_select(:status, …) { … }` render a form control already bound
|
|
13
|
+
to an action param — no hand-written `name: "value"` magic string to forget
|
|
14
|
+
(which silently leaves the action with no params). `reactive_field(:param,
|
|
15
|
+
**attrs)` returns just the attribute hash to spread onto any control; an
|
|
16
|
+
explicit `name:` still wins as an escape hatch. The trigger stays on the
|
|
17
|
+
button, so focusing the field doesn't dispatch and collapse edit mode.
|
|
18
|
+
- **`accepts_nested_attributes_for` helper (issue #24).** `nested_update!(:address,
|
|
19
|
+
address)` maps a declared nested param straight onto `<assoc>_attributes` and
|
|
20
|
+
carries the existing associated record's id, so `update_only:` matches it in
|
|
21
|
+
place instead of building a second `has_one` — replacing the per-editor
|
|
22
|
+
`*_attributes` + id-preservation boilerplate that was easy to get subtly wrong.
|
|
23
|
+
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
24
|
+
updating, for combining with other attributes.
|
|
25
|
+
- **`Response#also_update` / `#also_replace` (issue #25).** Re-render a companion
|
|
26
|
+
element alongside self without dropping to raw `turbo_stream_builder`:
|
|
27
|
+
`Response.replace(self).also_update("page_heading", html: @record.name)` adds an
|
|
28
|
+
update stream for an arbitrary DOM id (`html` is a string or a Phlex component,
|
|
29
|
+
rendered through the configured renderer), and `.also_replace(other_component)`
|
|
30
|
+
re-renders another Streamable component targeting its own `#id`. Both are
|
|
31
|
+
immutable and additive, so the self-replace still refreshes the signed token.
|
|
32
|
+
- **Boot-time integration guards (issue #26).** Two silent first-run failures now
|
|
33
|
+
surface a clear warning. A host catch-all route (`match "*path", …`) that
|
|
34
|
+
shadows the engine's appended `POST /reactive/actions` (every reactive POST
|
|
35
|
+
404s) is detected at boot via a route-recognition check
|
|
36
|
+
(`Phlex::Reactive.action_route_ok?`), logging how to exempt the path. And when
|
|
37
|
+
`data-controller="reactive"` elements are on the page but no controller
|
|
38
|
+
connected — the `lazyLoadControllersFrom` case where the gem's controller was
|
|
39
|
+
never registered — the client runtime logs a console warning naming the fix.
|
|
40
|
+
The README gains an "Integration troubleshooting" section covering both.
|
|
11
41
|
- **Nested & array param types (issue #16).** Action param schemas can now
|
|
12
42
|
declare arrays and nested hashes, not just scalars: wrap a type in an array
|
|
13
43
|
(`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
|
|
@@ -269,6 +310,9 @@ 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. |
|
|
272
316
|
|
|
273
317
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
274
318
|
in the schema is dropped before reaching your method.
|
|
@@ -337,6 +381,47 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
|
|
|
337
381
|
button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
338
382
|
```
|
|
339
383
|
|
|
384
|
+
**Binding inputs to action params (drop the magic `name:`).** A field's value
|
|
385
|
+
travels with an action only if its `name` equals the param. Hand-writing
|
|
386
|
+
`name: "value"` on every input is easy to forget — the action then silently gets
|
|
387
|
+
nothing. `reactive_input`/`reactive_select` emit the binding for you (the trigger
|
|
388
|
+
stays on the button, so focusing the field doesn't dispatch and collapse edit
|
|
389
|
+
mode):
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
action :save, params: { value: :string, status: :string }
|
|
393
|
+
|
|
394
|
+
def view_template
|
|
395
|
+
span(id:, **reactive_attrs) do
|
|
396
|
+
reactive_input(:value, value: @record.name) # <input name="value" …>
|
|
397
|
+
reactive_select(:status) do # <select name="status">…</select>
|
|
398
|
+
%w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
|
|
399
|
+
end
|
|
400
|
+
button(**mix(on(:save), data: { testid: "save" })) { "Save" }
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`reactive_field(:value, **attrs)` returns just the attribute hash if you'd rather
|
|
406
|
+
spread it onto a control yourself. An explicit `name:` still wins (escape hatch).
|
|
407
|
+
|
|
408
|
+
**Editing an associated record (`accepts_nested_attributes_for`).** `nested_update!`
|
|
409
|
+
maps a declared nested param straight onto `<assoc>_attributes` and carries the
|
|
410
|
+
existing record's id, so `update_only:` matches it in place instead of building a
|
|
411
|
+
second `has_one` (the boilerplate that's easy to get subtly wrong):
|
|
412
|
+
|
|
413
|
+
```ruby
|
|
414
|
+
# Account has_one :address; accepts_nested_attributes_for :address, update_only: true
|
|
415
|
+
action :save, params: { address: { street: :string, city: :string } }
|
|
416
|
+
|
|
417
|
+
def save(address:)
|
|
418
|
+
nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`nested_attributes(:address, address)` returns the id-merged hash without
|
|
423
|
+
updating, if you need to combine it with other attributes.
|
|
424
|
+
|
|
340
425
|
### `Phlex::Reactive::Response` — controlling the action's reply
|
|
341
426
|
|
|
342
427
|
By default an action re-renders its component in place. **Return** a
|
|
@@ -359,18 +444,29 @@ end
|
|
|
359
444
|
def approve = (@row.approve!; Response.remove(self)) # drop the element
|
|
360
445
|
def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
|
|
361
446
|
def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
447
|
+
|
|
448
|
+
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
|
|
449
|
+
def rename(value:) = (@account.update!(name: value); Response.replace(self).also_update("page_heading", html: @account.name))
|
|
362
450
|
```
|
|
363
451
|
|
|
364
452
|
| Builder | Reply |
|
|
365
453
|
|---|---|
|
|
366
454
|
| `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
|
|
367
|
-
| `.
|
|
455
|
+
| `.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` |
|
|
457
|
+
| `.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"`) |
|
|
368
458
|
| `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
369
459
|
| `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
370
460
|
| `Response.with(*streams)` / `#stream(*more)` | multi-stream |
|
|
371
461
|
|
|
372
|
-
`.flash`/`.stream
|
|
373
|
-
token always refreshes.
|
|
462
|
+
`.flash`/`.stream`/`.also_*` are additive on a self-replace, so the component's
|
|
463
|
+
signed token always refreshes.
|
|
464
|
+
|
|
465
|
+
> **`html:`/`content` escaping.** A plain string is **HTML-escaped** by Turbo, so
|
|
466
|
+
> `html: @account.name` is safe even for user-supplied values. To emit intentional
|
|
467
|
+
> markup, pass a **Phlex component** (`html: Heading.new(name: @record.name)`) —
|
|
468
|
+
> rendered and auto-escaped through the renderer — or an `html_safe` string for
|
|
469
|
+
> raw HTML you control.
|
|
374
470
|
|
|
375
471
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
376
472
|
|
|
@@ -36,6 +36,49 @@ if (typeof window !== "undefined") {
|
|
|
36
36
|
else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// --- Registration guard (issue #26 part 2) -------------------------------
|
|
40
|
+
// In a `lazyLoadControllersFrom("controllers", application)` app, only
|
|
41
|
+
// controllers under app/javascript/controllers/ are registered. This module
|
|
42
|
+
// lives outside that dir, so importing it isn't enough — `data-controller=
|
|
43
|
+
// "reactive"` does NOTHING until the host runs application.register("reactive",
|
|
44
|
+
// ...). The failure is silent: components render, but no action ever fires.
|
|
45
|
+
//
|
|
46
|
+
// We can't warn from connect() in that case (connect never runs). Instead, once
|
|
47
|
+
// the page is ready, if reactive elements exist but no controller has connected,
|
|
48
|
+
// the controller wasn't registered — so we warn, pointing at the fix.
|
|
49
|
+
let reactiveConnected = false
|
|
50
|
+
|
|
51
|
+
export function checkReactiveRegistration() {
|
|
52
|
+
if (reactiveConnected) return
|
|
53
|
+
if (typeof document === "undefined") return
|
|
54
|
+
const els = document.querySelectorAll('[data-controller~="reactive"]')
|
|
55
|
+
if (!els || els.length === 0) return
|
|
56
|
+
console.warn(
|
|
57
|
+
"[phlex-reactive] found " + els.length + ' element(s) with data-controller="reactive" ' +
|
|
58
|
+
"but the reactive controller never connected. It is loaded but not registered — " +
|
|
59
|
+
'add `application.register("reactive", ReactiveController)` (importmap) or import it ' +
|
|
60
|
+
"into app/javascript/controllers/ for lazyLoadControllersFrom apps. See the README."
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Test seams (no-ops in production usage).
|
|
65
|
+
export function __resetReactiveRegistrationForTest() {
|
|
66
|
+
reactiveConnected = false
|
|
67
|
+
}
|
|
68
|
+
export function __markReactiveConnectedForTest() {
|
|
69
|
+
reactiveConnected = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
73
|
+
// Defer past initial controller connection (a microtask/tick after ready).
|
|
74
|
+
const scheduleCheck = () => setTimeout(checkReactiveRegistration, 0)
|
|
75
|
+
if (document.readyState === "loading") {
|
|
76
|
+
document.addEventListener("DOMContentLoaded", scheduleCheck, { once: true })
|
|
77
|
+
} else {
|
|
78
|
+
scheduleCheck()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
// Register this controller eagerly (not lazily) so a click immediately after
|
|
40
83
|
// page load is never missed. The phlex-reactive engine auto-pins it with
|
|
41
84
|
// preload: true for importmap apps; see the README for esbuild/webpack.
|
|
@@ -47,6 +90,12 @@ export default class extends Controller {
|
|
|
47
90
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
48
91
|
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
49
92
|
|
|
93
|
+
// Mark that a reactive controller actually connected, so the registration
|
|
94
|
+
// guard above knows the controller was registered (issue #26 part 2).
|
|
95
|
+
connect() {
|
|
96
|
+
reactiveConnected = true
|
|
97
|
+
}
|
|
98
|
+
|
|
50
99
|
// Tear down any pending debounce timers when the controller leaves the DOM
|
|
51
100
|
// (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
|
|
52
101
|
// fired yet would later call #enqueue on a disconnected controller — a round
|
|
@@ -206,8 +206,72 @@ module Phlex
|
|
|
206
206
|
attrs
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
# Bind a form control's `name` to an action param so its value travels with
|
|
210
|
+
# the action — instead of hand-writing the magic `name: "value"` on every
|
|
211
|
+
# input and silently getting no params when you forget it (issue #23).
|
|
212
|
+
# Returns a Phlex attributes hash to spread onto any control:
|
|
213
|
+
# input(**reactive_field(:value, value: @record.name))
|
|
214
|
+
# select(**reactive_field(:status)) { ... }
|
|
215
|
+
# Extra attrs merge over the binding; an explicit name: still wins (escape
|
|
216
|
+
# hatch). The trigger (on(:save)) stays on the button, not the field — so
|
|
217
|
+
# focusing the input doesn't dispatch and collapse edit mode.
|
|
218
|
+
def reactive_field(param, **attrs)
|
|
219
|
+
{name: param.to_s, **attrs}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Render an <input> already bound to an action param (issue #23). Sugar for
|
|
223
|
+
# input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
|
|
224
|
+
# reactive_input(:value, value: @record.name, type: "text")
|
|
225
|
+
def reactive_input(param, **attrs)
|
|
226
|
+
input(**reactive_field(param, **attrs))
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Render a <select> bound to an action param (issue #23). The options block
|
|
230
|
+
# is the element's content, so the awkward FormBuilder positional split
|
|
231
|
+
# (where name: lands after the options/html-options args) goes away:
|
|
232
|
+
# reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
|
|
233
|
+
def reactive_select(param, **attrs, &block)
|
|
234
|
+
select(**reactive_field(param, **attrs), &block)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Map a declared nested param onto Rails' <assoc>_attributes, carrying the
|
|
238
|
+
# existing associated record's id so accepts_nested_attributes_for matches
|
|
239
|
+
# it IN PLACE instead of building a second one (issue #24). Returns the
|
|
240
|
+
# update hash; pass it to update!:
|
|
241
|
+
# def save(address:) = nested_update!(:address, address)
|
|
242
|
+
# The id is only added when the association already exists, so the first
|
|
243
|
+
# save (no associated record yet) creates one cleanly. The given attrs are
|
|
244
|
+
# not mutated.
|
|
245
|
+
def nested_attributes(association, attrs)
|
|
246
|
+
merged = attrs.dup
|
|
247
|
+
existing = reactive_record_for_nested.public_send(association)
|
|
248
|
+
merged[:id] = existing.id if existing
|
|
249
|
+
|
|
250
|
+
{"#{association}_attributes": merged}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Map a nested param onto <assoc>_attributes (with id preservation) AND
|
|
254
|
+
# apply it to the component's record in one call (issue #24). Extra keyword
|
|
255
|
+
# attributes update alongside the association.
|
|
256
|
+
# def save(address:, name:) = nested_update!(:address, address, name:)
|
|
257
|
+
def nested_update!(association, attrs, **extra)
|
|
258
|
+
reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
|
|
259
|
+
end
|
|
260
|
+
|
|
209
261
|
private
|
|
210
262
|
|
|
263
|
+
# The component's record, for the nested-attributes helpers. Requires a
|
|
264
|
+
# declared reactive_record (the nested helper only makes sense for a
|
|
265
|
+
# record-backed component).
|
|
266
|
+
def reactive_record_for_nested
|
|
267
|
+
key = self.class.reactive_record_key
|
|
268
|
+
unless key
|
|
269
|
+
raise Error, "#{self.class} must declare `reactive_record` to use nested_update!/nested_attributes"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
instance_variable_get(:"@#{key}")
|
|
273
|
+
end
|
|
274
|
+
|
|
211
275
|
# Signed identity payload: the class name plus whichever identity pieces
|
|
212
276
|
# the component declares — a record GlobalID (`gid`), signed state (`s`),
|
|
213
277
|
# 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
|
|
@@ -12,6 +12,7 @@ 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
|
|
@@ -44,8 +45,27 @@ module Phlex
|
|
|
44
45
|
# supplied by the caller because the render context is off-request
|
|
45
46
|
# (there is no Rails `flash`).
|
|
46
47
|
def flash_stream(_level, content, target:)
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
Phlex::Reactive.flash_builder.append(target, html: render_html(content))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build a turbo-stream that updates an arbitrary target id with `content`
|
|
52
|
+
# (a Phlex component instance or an HTML string). Used by #also_update to
|
|
53
|
+
# re-render a companion element that isn't itself a Streamable component.
|
|
54
|
+
def update_stream(target, content)
|
|
55
|
+
Phlex::Reactive.flash_builder.update(target, html: render_html(content))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resolve `content` to the HTML for a turbo-stream's `html:`. Two forms,
|
|
59
|
+
# both SAFE against injection by default:
|
|
60
|
+
# * a Phlex component instance — rendered through the configured
|
|
61
|
+
# renderer, which auto-escapes interpolated values.
|
|
62
|
+
# * any other value — coerced with to_s and handed to Turbo's
|
|
63
|
+
# TagBuilder, which HTML-ESCAPES a plain String. So a model value
|
|
64
|
+
# (`html: @record.name`) cannot inject markup. To emit intentional
|
|
65
|
+
# raw HTML, pass an `html_safe` String (Turbo leaves those verbatim)
|
|
66
|
+
# or a Phlex component. Same contract as the pre-existing flash_stream.
|
|
67
|
+
def render_html(content)
|
|
68
|
+
content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
|
|
49
69
|
end
|
|
50
70
|
end
|
|
51
71
|
|
|
@@ -76,6 +96,27 @@ module Phlex
|
|
|
76
96
|
stream(self.class.flash_stream(level, content, target:))
|
|
77
97
|
end
|
|
78
98
|
|
|
99
|
+
# Also re-render a COMPANION element alongside self — a page heading, a
|
|
100
|
+
# summary card, a badge that recomputes from the saved value (issue #25).
|
|
101
|
+
# `target` is the sibling element's DOM id. `html` is either:
|
|
102
|
+
# * a plain String — HTML-ESCAPED by Turbo, so a model value is safe:
|
|
103
|
+
# Response.replace(self).also_update("page_heading", html: @record.name)
|
|
104
|
+
# * a Phlex component — rendered + auto-escaped through the renderer (use
|
|
105
|
+
# this when the companion has its own markup), or an `html_safe` String
|
|
106
|
+
# for intentional raw HTML.
|
|
107
|
+
# Returns a NEW Response (immutable). The common "re-render self + N
|
|
108
|
+
# siblings" case no longer needs raw turbo_stream_builder.
|
|
109
|
+
def also_update(target, html:)
|
|
110
|
+
stream(self.class.update_stream(target, html))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Like #also_update, but renders ANOTHER Streamable component and replaces
|
|
114
|
+
# it by its own #id — for a companion that is itself a component.
|
|
115
|
+
# Response.replace(self).also_replace(SummaryCard.new(order: @order))
|
|
116
|
+
def also_replace(component)
|
|
117
|
+
stream(component.to_stream_replace)
|
|
118
|
+
end
|
|
119
|
+
|
|
79
120
|
def redirect? = !@redirect_url.nil?
|
|
80
121
|
def render_self? = @render_self
|
|
81
122
|
end
|
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; " \
|