phlex-reactive 0.2.5 → 0.2.6
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 +29 -0
- data/README.md +38 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +35 -2
- data/app/javascript/phlex/reactive/reactive_controller.js +19 -0
- data/lib/phlex/reactive/response.rb +83 -0
- data/lib/phlex/reactive/streamable.rb +7 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +20 -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: 6db6575334b8d59a054812e6815dc9b1968274a7917c66ec48c353dd95ba477d
|
|
4
|
+
data.tar.gz: 294d2d892c5e8a4b55466e0102b89fe945f7b4f23e1a882b06566c6898520f03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 362f900e12b5d4d0e3240d230b64b7ea1366e9109c1957c266d7695a7f3985b762ce29843c644c12dba6e91ffead0bf6ff7f7e73dc32eac046de79c5c5e3c8cc
|
|
7
|
+
data.tar.gz: 3fc968f66ea7892eba20e050e5be95ad75c878a5518052eaef4006a86b963ac3a3fd1e155d1d4b45f0ea05602d3db1decae1fbb3c75e3bd76cc93bc66a29a673
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.6]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Action response control via `Phlex::Reactive::Response`.** An action MAY now
|
|
14
|
+
return a `Response` to govern the actor's HTTP reply, instead of only the
|
|
15
|
+
implicit single re-render. Returning anything else keeps the legacy default
|
|
16
|
+
(re-render the component in place), so existing actions are unaffected.
|
|
17
|
+
- `Response.replace(self)` / `.update(self)` — explicit re-render.
|
|
18
|
+
- `Response.replace(self).flash(:error, msg_or_component)` — surface a
|
|
19
|
+
validation error / notice (the `#1` driver). `.flash` is additive on a
|
|
20
|
+
self-replace, so the component's signed token always refreshes. Flash
|
|
21
|
+
content is supplied explicitly (the render context is off-request — there is
|
|
22
|
+
no Rails `flash`); pass a string or a Phlex component. Target container is
|
|
23
|
+
`Phlex::Reactive.flash_target` (default `flash`).
|
|
24
|
+
- `Response.remove(self)` — drop the element (e.g. a moderation queue). New
|
|
25
|
+
instance helper `Streamable#to_stream_remove` backs it.
|
|
26
|
+
- `Response.redirect(url)` — client-side `Turbo.visit` for when the current
|
|
27
|
+
URL is dead (e.g. a slug rename). Rides a 200 turbo-stream carrying a
|
|
28
|
+
`reactive:visit` custom action (registered in the client), **not** an HTTP
|
|
29
|
+
3xx (which the client bails on). Pass a `*_url`.
|
|
30
|
+
- `Response.with(*streams)` / `#stream(*more)` — multi-stream (replace self +
|
|
31
|
+
a sibling component).
|
|
32
|
+
- The endpoint guarantees the component's own replace is present (token refresh)
|
|
33
|
+
for non-remove/redirect responses, and never double-prepends when the action
|
|
34
|
+
already included a self-targeted stream.
|
|
35
|
+
|
|
36
|
+
## [0.2.5]
|
|
37
|
+
|
|
9
38
|
### Fixed
|
|
10
39
|
|
|
11
40
|
- **Form submit navigated instead of running the reactive action.** A component
|
data/README.md
CHANGED
|
@@ -149,7 +149,8 @@ for broadcasting.
|
|
|
149
149
|
│ verify signed token (no state trusted)
|
|
150
150
|
│ rebuild component (record from DB)
|
|
151
151
|
│ run the whitelisted action
|
|
152
|
-
│ re-render → <turbo-stream replace id>
|
|
152
|
+
│ re-render → <turbo-stream replace id> (default; an action
|
|
153
|
+
│ may return a Response — see "Controlling the action's reply")
|
|
153
154
|
└──────── Turbo morphs it in ◀───────────────────────────────┘
|
|
154
155
|
|
|
155
156
|
...and for OTHER tabs/users:
|
|
@@ -254,7 +255,7 @@ The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase — see
|
|
|
254
255
|
| `.update` / `.append(target:)` / `.prepend(target:)` / `.remove` | The other Turbo Stream actions |
|
|
255
256
|
| `.broadcast_replace_to(*streamables, model:)` | Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
|
|
256
257
|
| `.broadcast_append_to(*streamables, target:, model:)` / `_update_` / `_prepend_` / `_remove_` | The broadcast variants |
|
|
257
|
-
| `#to_stream_replace` / `#to_stream_update` | Stream the *already-built* instance (used internally after an action) |
|
|
258
|
+
| `#to_stream_replace` / `#to_stream_update` / `#to_stream_remove` | Stream the *already-built* instance (used internally after an action / by `Response`) |
|
|
258
259
|
|
|
259
260
|
Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
260
261
|
|
|
@@ -284,6 +285,41 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
|
|
|
284
285
|
button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
285
286
|
```
|
|
286
287
|
|
|
288
|
+
### `Phlex::Reactive::Response` — controlling the action's reply
|
|
289
|
+
|
|
290
|
+
By default an action re-renders its component in place. **Return** a
|
|
291
|
+
`Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
|
|
292
|
+
cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
|
|
293
|
+
Returning anything else keeps the default, so existing actions are unaffected.
|
|
294
|
+
|
|
295
|
+
The snippets below alias the constant for brevity (`Response.replace(self)` won't
|
|
296
|
+
resolve to `Phlex::Reactive::Response` inside a namespaced component — fully
|
|
297
|
+
qualify it, or add the alias shown):
|
|
298
|
+
|
|
299
|
+
```ruby
|
|
300
|
+
Response = Phlex::Reactive::Response # or qualify each call below
|
|
301
|
+
|
|
302
|
+
def rename(title:)
|
|
303
|
+
return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
|
|
304
|
+
Response.replace(self)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def approve = (@row.approve!; Response.remove(self)) # drop the element
|
|
308
|
+
def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
|
|
309
|
+
def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
| Builder | Reply |
|
|
313
|
+
|---|---|
|
|
314
|
+
| `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
|
|
315
|
+
| `.flash(level, content, target: …)` | append a flash; `content` is a string or Phlex component (off-request — no Rails `flash`); target defaults to `Phlex::Reactive.flash_target` (`"flash"`) |
|
|
316
|
+
| `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
317
|
+
| `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
318
|
+
| `Response.with(*streams)` / `#stream(*more)` | multi-stream |
|
|
319
|
+
|
|
320
|
+
`.flash`/`.stream` are additive on a self-replace, so the component's signed
|
|
321
|
+
token always refreshes.
|
|
322
|
+
|
|
287
323
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
288
324
|
|
|
289
325
|
```ruby
|
|
@@ -36,9 +36,9 @@ module Phlex
|
|
|
36
36
|
component = component_class.from_identity(payload)
|
|
37
37
|
coerced = coerce_params(action_def.params)
|
|
38
38
|
|
|
39
|
-
run_action(component, action_def, coerced)
|
|
39
|
+
result = run_action(component, action_def, coerced)
|
|
40
40
|
|
|
41
|
-
render turbo_stream: component
|
|
41
|
+
render turbo_stream: response_streams(result, component)
|
|
42
42
|
rescue Phlex::Reactive::InvalidToken
|
|
43
43
|
head :bad_request
|
|
44
44
|
rescue ActiveRecord::RecordNotFound
|
|
@@ -69,6 +69,39 @@ module Phlex
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Turn the action's return value into the turbo-stream(s) to render for
|
|
73
|
+
# the actor. A Phlex::Reactive::Response is honored explicitly; any other
|
|
74
|
+
# value (the legacy contract — return value ignored) falls back to the
|
|
75
|
+
# implicit single replace, so existing actions are unaffected.
|
|
76
|
+
def response_streams(result, component)
|
|
77
|
+
return [component.to_stream_replace] unless result.is_a?(Phlex::Reactive::Response)
|
|
78
|
+
return [redirect_stream(result.redirect_url)] if result.redirect?
|
|
79
|
+
|
|
80
|
+
streams = result.streams
|
|
81
|
+
# Guarantee the component's signed identity token is refreshed unless the
|
|
82
|
+
# Response opted out (remove/redirect navigate away — handled above). The
|
|
83
|
+
# client reads the next token from the response body (#extractToken), so
|
|
84
|
+
# the real invariant is "a fresh data-reactive-token-value is present",
|
|
85
|
+
# NOT "some stream targets self". Checking the token directly is correct
|
|
86
|
+
# for replace AND update of self (both re-render the root via
|
|
87
|
+
# render_component, carrying the token), and still adds the fallback
|
|
88
|
+
# replace when a hand-built `with(...)` stream omits it. Idempotent: a
|
|
89
|
+
# Response.replace(self)/update(self) already carries the token, so we
|
|
90
|
+
# don't double the self-render.
|
|
91
|
+
if result.render_self? && streams.none? { |s| s.include?("data-reactive-token-value") }
|
|
92
|
+
streams = [component.to_stream_replace, *streams]
|
|
93
|
+
end
|
|
94
|
+
streams
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A 200 turbo-stream carrying a namespaced custom action the client turns
|
|
98
|
+
# into Turbo.visit — NOT an HTTP 3xx, which the client hard-bails on
|
|
99
|
+
# (response.redirected). The matching client handler is registered in
|
|
100
|
+
# reactive_controller.js.
|
|
101
|
+
def redirect_stream(url)
|
|
102
|
+
%(<turbo-stream action="reactive:visit" data-url="#{ERB::Util.html_escape(url)}"></turbo-stream>)
|
|
103
|
+
end
|
|
104
|
+
|
|
72
105
|
def transaction_wrapper(&block)
|
|
73
106
|
if defined?(::ActiveRecord::Base)
|
|
74
107
|
::ActiveRecord::Base.transaction(&block)
|
|
@@ -17,6 +17,25 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
17
17
|
// .broadcast_* methods — so a click and a background broadcast converge on
|
|
18
18
|
// one re-render unit.
|
|
19
19
|
//
|
|
20
|
+
// Custom turbo-stream action: the server tells the actor to full-navigate
|
|
21
|
+
// (e.g. the record's slug changed and the current URL is now dead). It rides a
|
|
22
|
+
// 200 turbo-stream — NOT an HTTP 3xx — so it never trips the response.redirected
|
|
23
|
+
// bail below (which still correctly catches real auth/CSRF redirects). Registered
|
|
24
|
+
// once on the Turbo global (no @hotwired/turbo import — the gem uses window.Turbo
|
|
25
|
+
// everywhere, and a named import is unreliable under importmap/esbuild).
|
|
26
|
+
export function registerReactiveVisit() {
|
|
27
|
+
const actions = window.Turbo?.StreamActions
|
|
28
|
+
if (!actions || actions["reactive:visit"]) return
|
|
29
|
+
actions["reactive:visit"] = function () {
|
|
30
|
+
const url = this.getAttribute("data-url")
|
|
31
|
+
if (url) window.Turbo.visit(url, { action: "advance" })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof window !== "undefined") {
|
|
35
|
+
if (window.Turbo) registerReactiveVisit()
|
|
36
|
+
else document.addEventListener("turbo:load", registerReactiveVisit, { once: true })
|
|
37
|
+
}
|
|
38
|
+
|
|
20
39
|
// Register this controller eagerly (not lazily) so a click immediately after
|
|
21
40
|
// page load is never missed. The phlex-reactive engine auto-pins it with
|
|
22
41
|
// preload: true for importmap apps; see the README for esbuild/webpack.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
# An explicit, immutable description of the ACTOR's HTTP response to a
|
|
6
|
+
# reactive action. An action MAY return one; if it returns anything else
|
|
7
|
+
# (the legacy contract — return value ignored), the endpoint falls back to
|
|
8
|
+
# the implicit single component.to_stream_replace.
|
|
9
|
+
#
|
|
10
|
+
# A Response governs ONLY the actor's HTTP reply. Cross-tab updates still go
|
|
11
|
+
# through Streamable's broadcast_*_to(..., exclude: reactive_connection_id).
|
|
12
|
+
#
|
|
13
|
+
# Response.replace(self) # re-render in place (the default, explicit)
|
|
14
|
+
# Response.replace(self).flash(:error, msg) # surface a validation error
|
|
15
|
+
# Response.remove(self) # drop the element (e.g. moderation queue)
|
|
16
|
+
# Response.redirect(article_url(@article)) # slug changed -> Turbo.visit the new URL
|
|
17
|
+
# Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
18
|
+
class Response
|
|
19
|
+
attr_reader :streams, :redirect_url
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Re-render the component in place (explicit form of today's default).
|
|
23
|
+
def replace(component) = new(streams: [component.to_stream_replace])
|
|
24
|
+
|
|
25
|
+
# Morph only inner HTML (preserves the root element + its token attr).
|
|
26
|
+
def update(component) = new(streams: [component.to_stream_update])
|
|
27
|
+
|
|
28
|
+
# Remove the component's element from the DOM. Uses the instance
|
|
29
|
+
# to_stream_remove (the component already knows its own #id — no
|
|
30
|
+
# class-builder reconstruction; works for record- and state-backed).
|
|
31
|
+
def remove(component) = new(streams: [component.to_stream_remove], render_self: false)
|
|
32
|
+
|
|
33
|
+
# Client-side full navigation (Turbo.visit). Use when the current URL
|
|
34
|
+
# is dead (slug rename) or the outcome belongs on another page. Pass a
|
|
35
|
+
# *_url (the off-request render context has no request host for *_path).
|
|
36
|
+
def redirect(url) = new(redirect_url: url, render_self: false)
|
|
37
|
+
|
|
38
|
+
# Escape hatch / multi-stream root: zero or more raw turbo-stream strings.
|
|
39
|
+
def with(*strings) = new(streams: strings.flatten)
|
|
40
|
+
|
|
41
|
+
# Build a flash turbo-stream that appends `content` into a host-app
|
|
42
|
+
# container. `content` is a Phlex component instance (rendered through
|
|
43
|
+
# the configured renderer so t()/url_for work) or a ready HTML string —
|
|
44
|
+
# supplied by the caller because the render context is off-request
|
|
45
|
+
# (there is no Rails `flash`).
|
|
46
|
+
def flash_stream(_level, content, target:)
|
|
47
|
+
html = content.is_a?(::Phlex::SGML) ? Phlex::Reactive.render(content) : content.to_s
|
|
48
|
+
Phlex::Reactive.flash_builder.append(target, html: html)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# render_self: when true (default for replace/update/with), the endpoint
|
|
53
|
+
# GUARANTEES the component's own replace is present so its
|
|
54
|
+
# data-reactive-token-value refreshes (the client extracts the next token
|
|
55
|
+
# from the response HTML). remove/redirect set it false (nothing stays).
|
|
56
|
+
def initialize(streams: [], redirect_url: nil, render_self: true)
|
|
57
|
+
@streams = streams.freeze
|
|
58
|
+
@redirect_url = redirect_url
|
|
59
|
+
@render_self = render_self
|
|
60
|
+
freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Append extra turbo-stream strings (a sibling component, a flash).
|
|
64
|
+
# Returns a NEW Response (immutable).
|
|
65
|
+
def stream(*more)
|
|
66
|
+
self.class.new(
|
|
67
|
+
streams: @streams + more.flatten,
|
|
68
|
+
redirect_url: @redirect_url,
|
|
69
|
+
render_self: @render_self
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append a flash turbo-stream into a host-app container (default
|
|
74
|
+
# <div id="flash">, configurable via Phlex::Reactive.flash_target).
|
|
75
|
+
def flash(level, content, target: Phlex::Reactive.flash_target)
|
|
76
|
+
stream(self.class.flash_stream(level, content, target:))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def redirect? = !@redirect_url.nil?
|
|
80
|
+
def render_self? = @render_self
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -187,6 +187,13 @@ module Phlex
|
|
|
187
187
|
def to_stream_update
|
|
188
188
|
self.class.turbo_stream_builder.update(id, html: self.class.render_component(self))
|
|
189
189
|
end
|
|
190
|
+
|
|
191
|
+
# Render THIS instance as a remove stream. The component already knows its
|
|
192
|
+
# own #id, so no record/class reconstruction is needed (works for record-
|
|
193
|
+
# and state-backed components alike). Used by Response.remove.
|
|
194
|
+
def to_stream_remove
|
|
195
|
+
self.class.turbo_stream_builder.remove(id)
|
|
196
|
+
end
|
|
190
197
|
end
|
|
191
198
|
end
|
|
192
199
|
end
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -78,6 +78,26 @@ module Phlex
|
|
|
78
78
|
@renderer ||= defined?(::ActionController::Base) ? ::ActionController::Base : nil
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# DOM id of the host-app container a Response#flash appends into.
|
|
82
|
+
# Default "flash"; override to match your layout's flash region.
|
|
83
|
+
def flash_target
|
|
84
|
+
@flash_target ||= "flash"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
attr_writer :flash_target
|
|
88
|
+
|
|
89
|
+
# Render a Phlex component to HTML with a full (off-request) view context.
|
|
90
|
+
def render(component)
|
|
91
|
+
renderer.render(component, layout: false)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A Turbo::Streams::TagBuilder bound to an off-request view context, used
|
|
95
|
+
# to build standalone streams (e.g. a Response flash append) not tied to a
|
|
96
|
+
# specific component's id.
|
|
97
|
+
def flash_builder
|
|
98
|
+
::Turbo::Streams::TagBuilder.new(renderer.new.view_context)
|
|
99
|
+
end
|
|
100
|
+
|
|
81
101
|
def base_controller_name
|
|
82
102
|
@base_controller_name ||= "ActionController::Base"
|
|
83
103
|
end
|
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.2.
|
|
4
|
+
version: 0.2.6
|
|
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/response.rb
|
|
132
133
|
- lib/phlex/reactive/streamable.rb
|
|
133
134
|
- lib/phlex/reactive/version.rb
|
|
134
135
|
homepage: https://github.com/mhenrixon/phlex-reactive
|