phlex-reactive 0.1.0 → 0.2.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 +29 -1
- data/README.md +47 -3
- data/app/controllers/phlex/reactive/actions_controller.rb +14 -7
- data/app/javascript/phlex/reactive/reactive_controller.js +25 -5
- data/lib/generators/phlex/reactive/component/USAGE +14 -0
- data/lib/generators/phlex/reactive/component/component_generator.rb +65 -0
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +64 -0
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +34 -0
- data/lib/generators/phlex/reactive/install/USAGE +12 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +81 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +24 -0
- data/lib/phlex/reactive/component.rb +17 -2
- data/lib/phlex/reactive/streamable.rb +49 -12
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +17 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25876f4b6f97e3cd517a4b5a575e3156ac7cadd4025e9f5cdf5f10b0bb001346
|
|
4
|
+
data.tar.gz: f5ea2f6cee8e7b17f66725549e98f2bc9d1cc3c29cf90acfb2d1b88c1176e2ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec2bc617e5a888e268e6e746f85a225e92d88145114687be55b825c9659b2d9ef75b35d25d67d6dc950070acadf3f387b2d2608108f4b91d2a0dfcd8982495e1
|
|
7
|
+
data.tar.gz: c2249b5dd3783ec5f9d4881290e15030ca4de9e6e4e1af238f239feb55c63df76a5c5f4d1098e9bb1022547766752a03825b63d7dac60830dcc6cd5526d929ca
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0]
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Actor-echo suppression.** `broadcast_*_to` now accepts `exclude:` (and
|
|
14
|
+
`visible_to:`), forwarded to the stream transport. Pass
|
|
15
|
+
`exclude: reactive_connection_id` from an action so the actor doesn't receive
|
|
16
|
+
the echo of its own broadcast — making `append`/`prepend` and optimistic UI
|
|
17
|
+
safe. The client sends its SSE connection id as `X-Pgbus-Connection`; the
|
|
18
|
+
endpoint exposes it via `Phlex::Reactive.current_connection_id` /
|
|
19
|
+
`reactive_connection_id`. Honored by pgbus; ignored (harmless) on Action Cable.
|
|
20
|
+
|
|
21
|
+
### Requires
|
|
22
|
+
|
|
23
|
+
- **pgbus >= 0.9.4** when using `exclude:`/`visible_to:` — it ships the
|
|
24
|
+
`exclude:`/`visible_to:`/`event:` forwarding through Turbo's broadcast
|
|
25
|
+
helpers. phlex-reactive still works on Action Cable without pgbus; the
|
|
26
|
+
`exclude:` argument is simply ignored there.
|
|
27
|
+
|
|
28
|
+
## [0.1.0]
|
|
29
|
+
|
|
9
30
|
### Added
|
|
10
31
|
|
|
11
32
|
- `Phlex::Reactive::Streamable` — class methods (`replace`, `update`, `append`,
|
|
@@ -22,5 +43,12 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
22
43
|
synchronous token threading, auto field collection; no per-feature controllers.
|
|
23
44
|
- Rails engine — mounts the action endpoint, registers and auto-pins the client
|
|
24
45
|
runtime for importmap apps.
|
|
46
|
+
- **Generators.** `rails g phlex:reactive:install` registers the `reactive`
|
|
47
|
+
Stimulus controller (eagerly) and writes a config initializer.
|
|
48
|
+
`rails g phlex:reactive:component Name [actions] [--record name | --state vars]`
|
|
49
|
+
scaffolds a reactive component (and an RSpec spec when the app uses RSpec),
|
|
50
|
+
state-backed by default or record-backed with `--record`.
|
|
25
51
|
|
|
26
|
-
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/
|
|
52
|
+
[Unreleased]: https://github.com/mhenrixon/phlex-reactive/compare/v0.2.0...HEAD
|
|
53
|
+
[0.2.0]: https://github.com/mhenrixon/phlex-reactive/compare/v0.1.0...v0.2.0
|
|
54
|
+
[0.1.0]: https://github.com/mhenrixon/phlex-reactive/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# phlex-reactive
|
|
2
2
|
|
|
3
|
+
[](https://github.com/mhenrixon/phlex-reactive/actions/workflows/main.yml)
|
|
4
|
+
[](https://rubygems.org/gems/phlex-reactive)
|
|
5
|
+
[](https://mhenrixon.github.io/phlex-reactive)
|
|
6
|
+
|
|
3
7
|
**Reactive [Phlex](https://www.phlex.fun) components for Rails — Livewire-style
|
|
4
8
|
actions and live cross-tab updates, without writing Stimulus controllers or
|
|
5
9
|
hand-picking Turbo Stream targets.**
|
|
6
10
|
|
|
11
|
+
📖 **[Full documentation](https://mhenrixon.github.io/phlex-reactive)**
|
|
12
|
+
|
|
7
13
|
```ruby
|
|
8
14
|
class Counter < ApplicationComponent
|
|
9
15
|
include Phlex::Reactive::Streamable
|
|
@@ -75,9 +81,19 @@ gem "phlex-reactive"
|
|
|
75
81
|
bundle install
|
|
76
82
|
```
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
Then run the installer — it registers the client controller and writes a config
|
|
85
|
+
initializer:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bin/rails generate phlex:reactive:install
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
That's all for **importmap** apps: the engine mounts the action endpoint at
|
|
92
|
+
`/reactive/actions` and auto-pins (and preloads) the client runtime, and the
|
|
93
|
+
installer adds the eager registration below to your Stimulus entrypoint.
|
|
94
|
+
|
|
95
|
+
<details>
|
|
96
|
+
<summary>What the installer wires (or do it by hand)</summary>
|
|
81
97
|
|
|
82
98
|
```js
|
|
83
99
|
// app/javascript/controllers/index.js
|
|
@@ -86,6 +102,21 @@ import ReactiveController from "phlex/reactive/reactive_controller"
|
|
|
86
102
|
application.register("reactive", ReactiveController)
|
|
87
103
|
```
|
|
88
104
|
|
|
105
|
+
Register eagerly (not lazily) so a click immediately after load is never missed.
|
|
106
|
+
</details>
|
|
107
|
+
|
|
108
|
+
### Scaffold a component
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# state-backed (record-less)
|
|
112
|
+
bin/rails generate phlex:reactive:component Counter increment decrement
|
|
113
|
+
|
|
114
|
+
# record-backed (signed GlobalID identity)
|
|
115
|
+
bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Generates the component (and an RSpec spec if your app uses RSpec).
|
|
119
|
+
|
|
89
120
|
<details>
|
|
90
121
|
<summary>esbuild / webpack / bun</summary>
|
|
91
122
|
|
|
@@ -234,6 +265,19 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
234
265
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
235
266
|
in the schema is dropped before reaching your method.
|
|
236
267
|
|
|
268
|
+
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
269
|
+
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
270
|
+
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
# ✅ merges cleanly (data-action survives, your data-testid/class are added)
|
|
274
|
+
button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
|
|
275
|
+
div(**mix(reactive_attrs, id:, class: "card")) { ... }
|
|
276
|
+
|
|
277
|
+
# ❌ the extra data: overwrites on()'s data:, so the action never binds
|
|
278
|
+
button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
279
|
+
```
|
|
280
|
+
|
|
237
281
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
238
282
|
|
|
239
283
|
```ruby
|
|
@@ -52,12 +52,19 @@ module Phlex
|
|
|
52
52
|
# Run the action inside a transaction so transactional broadcasts (pgbus
|
|
53
53
|
# broadcasts_to ... durable:) defer to after_commit and never fire for a
|
|
54
54
|
# rolled-back change. Override to add per-request instrumentation.
|
|
55
|
+
#
|
|
56
|
+
# The actor's SSE connection id (sent as X-Pgbus-Connection) is exposed
|
|
57
|
+
# for the duration of the action via Phlex::Reactive.current_connection_id,
|
|
58
|
+
# so a broadcast in the action can pass exclude: reactive_connection_id
|
|
59
|
+
# and skip the actor's own echo.
|
|
55
60
|
def run_action(component, action_def, coerced)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
Phlex::Reactive.with_connection_id(request.headers["X-Pgbus-Connection"]) do
|
|
62
|
+
transaction_wrapper do
|
|
63
|
+
if coerced.any?
|
|
64
|
+
component.public_send(action_def.name, **coerced)
|
|
65
|
+
else
|
|
66
|
+
component.public_send(action_def.name)
|
|
67
|
+
end
|
|
61
68
|
end
|
|
62
69
|
end
|
|
63
70
|
end
|
|
@@ -97,7 +104,7 @@ module Phlex
|
|
|
97
104
|
def coerce(value, type)
|
|
98
105
|
case type
|
|
99
106
|
when :integer then value.to_i
|
|
100
|
-
when :float
|
|
107
|
+
when :float then value.to_f
|
|
101
108
|
when :boolean then ActiveModel::Type::Boolean.new.cast(value)
|
|
102
109
|
else value.to_s
|
|
103
110
|
end
|
|
@@ -107,7 +114,7 @@ module Phlex
|
|
|
107
114
|
# already gates this; defense in depth against constant injection.
|
|
108
115
|
def resolve_component(name)
|
|
109
116
|
klass = name.to_s.safe_constantize
|
|
110
|
-
unless klass
|
|
117
|
+
unless klass&.respond_to?(:reactive_action?) && klass.include?(Phlex::Reactive::Component)
|
|
111
118
|
raise Phlex::Reactive::InvalidToken
|
|
112
119
|
end
|
|
113
120
|
|
|
@@ -60,13 +60,20 @@ export default class extends Controller {
|
|
|
60
60
|
this.element.setAttribute("aria-busy", "true")
|
|
61
61
|
|
|
62
62
|
try {
|
|
63
|
+
const headers = {
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
Accept: "text/vnd.turbo-stream.html",
|
|
66
|
+
"X-CSRF-Token": this.#csrfToken(),
|
|
67
|
+
}
|
|
68
|
+
// Send the pgbus SSE connection id (if subscribed) so the server can
|
|
69
|
+
// exclude this connection from its own broadcast echo — the actor
|
|
70
|
+
// already gets the action's HTTP response. Harmless without pgbus.
|
|
71
|
+
const connectionId = this.#connectionId()
|
|
72
|
+
if (connectionId) headers["X-Pgbus-Connection"] = connectionId
|
|
73
|
+
|
|
63
74
|
const response = await fetch(this.#actionPath(), {
|
|
64
75
|
method: "POST",
|
|
65
|
-
headers
|
|
66
|
-
"Content-Type": "application/json",
|
|
67
|
-
Accept: "text/vnd.turbo-stream.html",
|
|
68
|
-
"X-CSRF-Token": this.#csrfToken(),
|
|
69
|
-
},
|
|
76
|
+
headers,
|
|
70
77
|
body,
|
|
71
78
|
credentials: "same-origin",
|
|
72
79
|
})
|
|
@@ -146,4 +153,17 @@ export default class extends Controller {
|
|
|
146
153
|
#csrfToken() {
|
|
147
154
|
return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
148
155
|
}
|
|
156
|
+
|
|
157
|
+
// The pgbus SSE connection id, if the page is subscribed to a stream. pgbus
|
|
158
|
+
// reflects it onto the <pgbus-stream-source connection-id="…"> element (and
|
|
159
|
+
// apps may mirror it to <meta name="pgbus-connection-id">). Returns null
|
|
160
|
+
// when not present (e.g. no pgbus, or no active subscription) — the header
|
|
161
|
+
// is then simply omitted.
|
|
162
|
+
#connectionId() {
|
|
163
|
+
return (
|
|
164
|
+
document.querySelector("pgbus-stream-source[connection-id]")?.getAttribute("connection-id") ||
|
|
165
|
+
document.querySelector('meta[name="pgbus-connection-id"]')?.content ||
|
|
166
|
+
null
|
|
167
|
+
)
|
|
168
|
+
}
|
|
149
169
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Scaffolds a reactive Phlex component (and an RSpec spec) with the given
|
|
3
|
+
actions. State-backed by default; pass --record for a record-backed
|
|
4
|
+
component whose identity is a signed GlobalID.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
bin/rails generate phlex:reactive:component Counter increment decrement
|
|
8
|
+
State-backed component with two actions, signed state (default :value).
|
|
9
|
+
|
|
10
|
+
bin/rails generate phlex:reactive:component Counter increment --state count
|
|
11
|
+
State-backed, signing @count.
|
|
12
|
+
|
|
13
|
+
bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
|
|
14
|
+
Record-backed component; identity = todo.to_gid; #id = dom_id(@todo).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails g phlex:reactive:component Counter [actions] [options]`
|
|
9
|
+
#
|
|
10
|
+
# Examples:
|
|
11
|
+
# rails g phlex:reactive:component Counter increment decrement
|
|
12
|
+
# → state-backed component with two actions
|
|
13
|
+
#
|
|
14
|
+
# rails g phlex:reactive:component Todos::Item toggle rename --record=todo
|
|
15
|
+
# → record-backed component (signed GlobalID) with two actions
|
|
16
|
+
#
|
|
17
|
+
# Generates the component under app/components and a matching spec under
|
|
18
|
+
# spec/components (or test/components for Minitest apps).
|
|
19
|
+
class ComponentGenerator < ::Rails::Generators::NamedBase
|
|
20
|
+
source_root File.expand_path("templates", __dir__)
|
|
21
|
+
|
|
22
|
+
argument :actions, type: :array, default: [], banner: "action action"
|
|
23
|
+
|
|
24
|
+
class_option :record, type: :string, default: nil,
|
|
25
|
+
desc: "Record-backed component: the init kwarg / GlobalID record name (e.g. --record=todo)"
|
|
26
|
+
class_option :state, type: :array, default: nil,
|
|
27
|
+
desc: "State-backed: instance vars to sign (e.g. --state=count open). Default for record-less."
|
|
28
|
+
|
|
29
|
+
def create_component
|
|
30
|
+
@component_path = File.join("app/components", class_path, "#{file_name}.rb")
|
|
31
|
+
template "component.rb.erb", File.join(destination_root, @component_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_spec
|
|
35
|
+
return unless defined_rspec?
|
|
36
|
+
|
|
37
|
+
@spec_path = File.join("spec/components", class_path, "#{file_name}_spec.rb")
|
|
38
|
+
template "component_spec.rb.erb", File.join(destination_root, @spec_path)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def record_name
|
|
44
|
+
options[:record]&.to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_backed?
|
|
48
|
+
record_name.present?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def state_keys
|
|
52
|
+
options[:state] || (record_backed? ? [] : %w[value])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def action_names
|
|
56
|
+
actions.presence || %w[example_action]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def defined_rspec?
|
|
60
|
+
File.directory?(File.join(destination_root, "spec"))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %> < ApplicationComponent
|
|
4
|
+
include Phlex::Reactive::Streamable
|
|
5
|
+
include Phlex::Reactive::Component
|
|
6
|
+
|
|
7
|
+
<% if record_backed? -%>
|
|
8
|
+
reactive_record :<%= record_name %>
|
|
9
|
+
<% action_names.each do |a| -%>
|
|
10
|
+
action :<%= a %>
|
|
11
|
+
<% end -%>
|
|
12
|
+
|
|
13
|
+
def initialize(<%= record_name %>:)
|
|
14
|
+
@<%= record_name %> = <%= record_name %>
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def id = dom_id(@<%= record_name %>)
|
|
18
|
+
<% action_names.each do |a| -%>
|
|
19
|
+
|
|
20
|
+
def <%= a %>
|
|
21
|
+
# authorize! @<%= record_name %>, :update? # authorize mutating actions
|
|
22
|
+
# @<%= record_name %>.update!(...)
|
|
23
|
+
end
|
|
24
|
+
<% end -%>
|
|
25
|
+
|
|
26
|
+
def view_template
|
|
27
|
+
div(id:, **reactive_attrs) do
|
|
28
|
+
<% action_names.each do |a| -%>
|
|
29
|
+
button(**on(:<%= a %>)) { "<%= a.to_s.humanize %>" }
|
|
30
|
+
<% end -%>
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
<% else -%>
|
|
34
|
+
reactive_state <%= state_keys.map { |k| ":#{k}" }.join(", ") %>
|
|
35
|
+
<% action_names.each do |a| -%>
|
|
36
|
+
action :<%= a %>
|
|
37
|
+
<% end -%>
|
|
38
|
+
|
|
39
|
+
def initialize(<%= state_keys.map { |k| "#{k}: nil" }.join(", ") %>)
|
|
40
|
+
<% state_keys.each do |k| -%>
|
|
41
|
+
@<%= k %> = <%= k %>
|
|
42
|
+
<% end -%>
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def id = "<%= file_name.dasherize %>"
|
|
46
|
+
<% action_names.each do |a| -%>
|
|
47
|
+
|
|
48
|
+
def <%= a %>
|
|
49
|
+
# mutate @<%= state_keys.first %> here
|
|
50
|
+
end
|
|
51
|
+
<% end -%>
|
|
52
|
+
|
|
53
|
+
def view_template
|
|
54
|
+
div(id:, **reactive_attrs) do
|
|
55
|
+
<% state_keys.each do |k| -%>
|
|
56
|
+
span(data: { <%= file_name %>_<%= k %>: true }) { @<%= k %>.to_s }
|
|
57
|
+
<% end -%>
|
|
58
|
+
<% action_names.each do |a| -%>
|
|
59
|
+
button(**on(:<%= a %>)) { "<%= a.to_s.humanize %>" }
|
|
60
|
+
<% end -%>
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
<% end -%>
|
|
64
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe <%= class_name %> do
|
|
6
|
+
describe "reactive declarations" do
|
|
7
|
+
<% action_names.each do |a| -%>
|
|
8
|
+
it "declares the :<%= a %> action" do
|
|
9
|
+
expect(described_class.reactive_action?(:<%= a %>)).to be(true)
|
|
10
|
+
end
|
|
11
|
+
<% end -%>
|
|
12
|
+
end
|
|
13
|
+
<% if record_backed? -%>
|
|
14
|
+
|
|
15
|
+
describe "identity" do
|
|
16
|
+
it "signs the record's GlobalID into a verifiable token" do
|
|
17
|
+
# record = <%= record_name %>_fixture_or_factory
|
|
18
|
+
# token = described_class.new(<%= record_name %>: record).send(:reactive_token)
|
|
19
|
+
# expect(Phlex::Reactive.verify(token)["gid"]).to eq(record.to_gid.to_s)
|
|
20
|
+
skip "provide a <%= record_name %> record to assert identity round-trip"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
<% else -%>
|
|
24
|
+
|
|
25
|
+
describe "identity" do
|
|
26
|
+
it "signs state into a verifiable token and rebuilds it" do
|
|
27
|
+
component = described_class.new(<%= state_keys.map { |k| "#{k}: #{k == state_keys.first ? "1" : "nil"}" }.join(", ") %>)
|
|
28
|
+
payload = Phlex::Reactive.verify(component.send(:reactive_token))
|
|
29
|
+
expect(payload["c"]).to eq("<%= class_name %>")
|
|
30
|
+
expect(described_class.from_identity(payload)).to be_a(described_class)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
<% end -%>
|
|
34
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Sets up phlex-reactive in your app: registers the generic `reactive`
|
|
3
|
+
Stimulus controller (eagerly) and writes a config initializer.
|
|
4
|
+
|
|
5
|
+
The action endpoint (POST /reactive/actions) and the client auto-pin are
|
|
6
|
+
provided by the engine, so this only wires the host-app bits.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
bin/rails generate phlex:reactive:install
|
|
10
|
+
|
|
11
|
+
Registers the controller in app/javascript/controllers/index.js and
|
|
12
|
+
creates config/initializers/phlex_reactive.rb.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Generators
|
|
8
|
+
# `rails g phlex:reactive:install`
|
|
9
|
+
#
|
|
10
|
+
# One-command setup for phlex-reactive:
|
|
11
|
+
# * registers the generic `reactive` Stimulus controller (eagerly)
|
|
12
|
+
# * writes a config initializer with the common options commented out
|
|
13
|
+
#
|
|
14
|
+
# The action endpoint and client auto-pin are provided by the engine, so
|
|
15
|
+
# this generator only wires the bits that live in the host app.
|
|
16
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
IMPORT_LINE = 'import ReactiveController from "phlex/reactive/reactive_controller"'
|
|
20
|
+
REGISTER_LINE = 'application.register("reactive", ReactiveController)'
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
template "phlex_reactive.rb.erb", "config/initializers/phlex_reactive.rb"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register the controller eagerly in the Stimulus entrypoint. Eager (not
|
|
27
|
+
# lazy) so a click immediately after page load is never missed.
|
|
28
|
+
def register_stimulus_controller
|
|
29
|
+
index = stimulus_index_path
|
|
30
|
+
|
|
31
|
+
unless index
|
|
32
|
+
say_status :skip, "no Stimulus controllers entrypoint found — register manually:\n" \
|
|
33
|
+
" #{IMPORT_LINE}\n #{REGISTER_LINE}", :yellow
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if File.read(index).include?(REGISTER_LINE)
|
|
38
|
+
say_status :identical, relative(index), :blue
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
inject_into_file index, after: %r{import \{ application \} from ["']controllers/application["']\n} do
|
|
43
|
+
"#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fallback: if the standard import line wasn't found, append both lines.
|
|
47
|
+
unless File.read(index).include?(REGISTER_LINE)
|
|
48
|
+
append_to_file index, "\n#{IMPORT_LINE}\n#{REGISTER_LINE}\n"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def show_post_install
|
|
53
|
+
say_status :info, "phlex-reactive installed. Include the mixins in a component:", :green
|
|
54
|
+
say <<~MSG
|
|
55
|
+
|
|
56
|
+
class Counter < ApplicationComponent
|
|
57
|
+
include Phlex::Reactive::Streamable
|
|
58
|
+
include Phlex::Reactive::Component
|
|
59
|
+
# ... reactive_state / reactive_record, action :name, on(:name) ...
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Scaffold one with: rails g phlex:reactive:component Counter
|
|
63
|
+
MSG
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def stimulus_index_path
|
|
69
|
+
%w[
|
|
70
|
+
app/javascript/controllers/index.js
|
|
71
|
+
app/javascript/controllers/application.js
|
|
72
|
+
].map { |p| File.join(destination_root, p) }.find { |p| File.exist?(p) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def relative(path)
|
|
76
|
+
path.sub("#{destination_root}/", "")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# phlex-reactive configuration. The engine mounts POST /reactive/actions and
|
|
4
|
+
# auto-pins the client runtime; these options are the ones you typically tune.
|
|
5
|
+
|
|
6
|
+
# Inherit auth/CSRF/Current on the action endpoint (recommended for real apps).
|
|
7
|
+
# Ensure the endpoint isn't force-redirected to a login page for logged-out
|
|
8
|
+
# users if you have public reactive components.
|
|
9
|
+
# Phlex::Reactive.base_controller_name = "ApplicationController"
|
|
10
|
+
|
|
11
|
+
# Render components with your app's view context (helpers, Current, etc.).
|
|
12
|
+
# Phlex::Reactive.renderer = ApplicationController
|
|
13
|
+
|
|
14
|
+
# Render your authorization library's error as 403 from a reactive action.
|
|
15
|
+
# Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
|
|
16
|
+
# Phlex::Reactive.authorization_errors = [ActionPolicy::Unauthorized]
|
|
17
|
+
|
|
18
|
+
# Sign identity tokens with a dedicated key instead of secret_key_base.
|
|
19
|
+
# Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
|
|
20
|
+
|
|
21
|
+
# Change the action endpoint path (default "/reactive/actions"). If you change
|
|
22
|
+
# it, expose it to the client with:
|
|
23
|
+
# <meta name="phlex-reactive-action-path" content="<%%= Phlex::Reactive.action_path %>">
|
|
24
|
+
# Phlex::Reactive.action_path = "/_r/actions"
|
|
@@ -116,6 +116,21 @@ module Phlex
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# The acting client's SSE connection id during the current action (nil
|
|
120
|
+
# outside an action, or when the client isn't subscribed to a stream).
|
|
121
|
+
# Pass it as `exclude:` when broadcasting from an action so the actor
|
|
122
|
+
# doesn't receive the echo of its own change — it already gets the
|
|
123
|
+
# action's HTTP response:
|
|
124
|
+
#
|
|
125
|
+
# def send_message(body:)
|
|
126
|
+
# msg = ChatMessage.create!(room: @room, body:)
|
|
127
|
+
# ChatMessage::Item.broadcast_append_to("chat", @room,
|
|
128
|
+
# target: "messages", model: msg, exclude: reactive_connection_id)
|
|
129
|
+
# end
|
|
130
|
+
def reactive_connection_id
|
|
131
|
+
Phlex::Reactive.current_connection_id
|
|
132
|
+
end
|
|
133
|
+
|
|
119
134
|
# Root-element attributes: marks the element reactive and carries the
|
|
120
135
|
# signed identity token. Spread onto the root:
|
|
121
136
|
# div(id:, **reactive_attrs) { ... }
|
|
@@ -154,12 +169,12 @@ module Phlex
|
|
|
154
169
|
payload =
|
|
155
170
|
if self.class.reactive_record_key
|
|
156
171
|
record = instance_variable_get(:"@#{self.class.reactive_record_key}")
|
|
157
|
-
{
|
|
172
|
+
{"c" => self.class.name, "gid" => record.to_gid.to_s}
|
|
158
173
|
else
|
|
159
174
|
state = self.class.reactive_state_keys.to_h do |k|
|
|
160
175
|
[k.to_s, instance_variable_get(:"@#{k}").as_json]
|
|
161
176
|
end
|
|
162
|
-
{
|
|
177
|
+
{"c" => self.class.name, "s" => state}
|
|
163
178
|
end
|
|
164
179
|
|
|
165
180
|
Phlex::Reactive.sign(payload)
|
|
@@ -35,11 +35,18 @@ module Phlex
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def component_args(model, options)
|
|
38
|
-
{
|
|
38
|
+
{model_param_name => model}.merge(options)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Turbo::Streams::TagBuilder needs a real VIEW CONTEXT (it calls
|
|
42
|
+
# `.formats` on it), not a controller class. Build one off-request from
|
|
43
|
+
# the configured renderer controller. Memoized per class.
|
|
41
44
|
def turbo_stream_builder
|
|
42
|
-
::Turbo::Streams::TagBuilder.new(
|
|
45
|
+
::Turbo::Streams::TagBuilder.new(turbo_view_context)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def turbo_view_context
|
|
49
|
+
renderer.new.view_context
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
# Render a component to HTML with a full Rails view context. Routing
|
|
@@ -83,37 +90,49 @@ module Phlex
|
|
|
83
90
|
# broadcaster builds the key itself, and double-keying can trip the
|
|
84
91
|
# transport's separator guard.
|
|
85
92
|
|
|
86
|
-
|
|
93
|
+
# `exclude:` / `visible_to:` are TRANSPORT options forwarded to the
|
|
94
|
+
# stream (pgbus), not component init args. `exclude:` is the actor's
|
|
95
|
+
# connection id — pass it to suppress the actor's own echo (the actor
|
|
96
|
+
# already got the action's HTTP response). With Action Cable these are
|
|
97
|
+
# ignored; with pgbus they reach the dispatcher. See docs/broadcasting.
|
|
98
|
+
def broadcast_replace_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
87
99
|
component = build(model, options)
|
|
88
100
|
::Turbo::StreamsChannel.broadcast_replace_to(
|
|
89
|
-
*streamables, target: component.id, html: render_component(component)
|
|
101
|
+
*streamables, target: component.id, html: render_component(component),
|
|
102
|
+
**broadcast_transport_opts(exclude:, visible_to:)
|
|
90
103
|
)
|
|
91
104
|
end
|
|
92
105
|
|
|
93
|
-
def broadcast_update_to(*streamables, model: nil, **options)
|
|
106
|
+
def broadcast_update_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
94
107
|
component = build(model, options)
|
|
95
108
|
::Turbo::StreamsChannel.broadcast_update_to(
|
|
96
|
-
*streamables, target: component.id, html: render_component(component)
|
|
109
|
+
*streamables, target: component.id, html: render_component(component),
|
|
110
|
+
**broadcast_transport_opts(exclude:, visible_to:)
|
|
97
111
|
)
|
|
98
112
|
end
|
|
99
113
|
|
|
100
|
-
def broadcast_append_to(*streamables, target:, model: nil, **options)
|
|
114
|
+
def broadcast_append_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
101
115
|
component = build(model, options)
|
|
102
116
|
::Turbo::StreamsChannel.broadcast_append_to(
|
|
103
|
-
*streamables, target:, html: render_component(component)
|
|
117
|
+
*streamables, target:, html: render_component(component),
|
|
118
|
+
**broadcast_transport_opts(exclude:, visible_to:)
|
|
104
119
|
)
|
|
105
120
|
end
|
|
106
121
|
|
|
107
|
-
def broadcast_prepend_to(*streamables, target:, model: nil, **options)
|
|
122
|
+
def broadcast_prepend_to(*streamables, target:, model: nil, exclude: nil, visible_to: nil, **options)
|
|
108
123
|
component = build(model, options)
|
|
109
124
|
::Turbo::StreamsChannel.broadcast_prepend_to(
|
|
110
|
-
*streamables, target:, html: render_component(component)
|
|
125
|
+
*streamables, target:, html: render_component(component),
|
|
126
|
+
**broadcast_transport_opts(exclude:, visible_to:)
|
|
111
127
|
)
|
|
112
128
|
end
|
|
113
129
|
|
|
114
|
-
def broadcast_remove_to(*streamables, model: nil, **options)
|
|
130
|
+
def broadcast_remove_to(*streamables, model: nil, exclude: nil, visible_to: nil, **options)
|
|
115
131
|
component = build(model, options)
|
|
116
|
-
::Turbo::StreamsChannel.broadcast_remove_to(
|
|
132
|
+
::Turbo::StreamsChannel.broadcast_remove_to(
|
|
133
|
+
*streamables, target: component.id,
|
|
134
|
+
**broadcast_transport_opts(exclude:, visible_to:)
|
|
135
|
+
)
|
|
117
136
|
end
|
|
118
137
|
|
|
119
138
|
private
|
|
@@ -122,6 +141,15 @@ module Phlex
|
|
|
122
141
|
new(**(model ? component_args(model, options) : options))
|
|
123
142
|
end
|
|
124
143
|
|
|
144
|
+
# Only include transport opts that were actually given, so on Action
|
|
145
|
+
# Cable (which doesn't accept them) the common no-opts call is unchanged.
|
|
146
|
+
def broadcast_transport_opts(exclude:, visible_to:)
|
|
147
|
+
opts = {}
|
|
148
|
+
opts[:exclude] = exclude unless exclude.nil?
|
|
149
|
+
opts[:visible_to] = visible_to unless visible_to.nil?
|
|
150
|
+
opts
|
|
151
|
+
end
|
|
152
|
+
|
|
125
153
|
def renderer
|
|
126
154
|
Phlex::Reactive.renderer
|
|
127
155
|
end
|
|
@@ -133,6 +161,15 @@ module Phlex
|
|
|
133
161
|
raise NotImplementedError, "#{self.class} must implement #id for Turbo Stream targeting"
|
|
134
162
|
end
|
|
135
163
|
|
|
164
|
+
# Render-context-free dom_id, safe to use inside `#id`. The streamable
|
|
165
|
+
# machinery calls `#id` BEFORE rendering, so Phlex's render-time `dom_id`
|
|
166
|
+
# helper would raise HelpersCalledBeforeRenderError. This delegates to
|
|
167
|
+
# ActionView::RecordIdentifier, which works anywhere — so
|
|
168
|
+
# `def id = dom_id(@todo)` is safe.
|
|
169
|
+
def dom_id(record, prefix = nil)
|
|
170
|
+
::ActionView::RecordIdentifier.dom_id(record, prefix)
|
|
171
|
+
end
|
|
172
|
+
|
|
136
173
|
# Render THIS already-built instance as a replace stream (used by the
|
|
137
174
|
# reactive action endpoint after an action mutated state).
|
|
138
175
|
def to_stream_replace
|
data/lib/phlex/reactive.rb
CHANGED
|
@@ -90,6 +90,23 @@ module Phlex
|
|
|
90
90
|
verifier.generate(payload, purpose: IDENTITY_PURPOSE)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# The acting client's SSE connection id during an action, or nil. Set by
|
|
94
|
+
# the ActionsController from the X-Pgbus-Connection header. A component
|
|
95
|
+
# action passes `exclude: Phlex::Reactive.current_connection_id` (or the
|
|
96
|
+
# `reactive_connection_id` helper) to suppress the actor's own broadcast
|
|
97
|
+
# echo.
|
|
98
|
+
def current_connection_id
|
|
99
|
+
Thread.current[:phlex_reactive_connection_id]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def with_connection_id(connection_id)
|
|
103
|
+
previous = Thread.current[:phlex_reactive_connection_id]
|
|
104
|
+
Thread.current[:phlex_reactive_connection_id] = connection_id.presence
|
|
105
|
+
yield
|
|
106
|
+
ensure
|
|
107
|
+
Thread.current[:phlex_reactive_connection_id] = previous
|
|
108
|
+
end
|
|
109
|
+
|
|
93
110
|
private
|
|
94
111
|
|
|
95
112
|
def default_verifier
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -118,6 +118,13 @@ files:
|
|
|
118
118
|
- README.md
|
|
119
119
|
- app/controllers/phlex/reactive/actions_controller.rb
|
|
120
120
|
- app/javascript/phlex/reactive/reactive_controller.js
|
|
121
|
+
- lib/generators/phlex/reactive/component/USAGE
|
|
122
|
+
- lib/generators/phlex/reactive/component/component_generator.rb
|
|
123
|
+
- lib/generators/phlex/reactive/component/templates/component.rb.erb
|
|
124
|
+
- lib/generators/phlex/reactive/component/templates/component_spec.rb.erb
|
|
125
|
+
- lib/generators/phlex/reactive/install/USAGE
|
|
126
|
+
- lib/generators/phlex/reactive/install/install_generator.rb
|
|
127
|
+
- lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb
|
|
121
128
|
- lib/phlex-reactive.rb
|
|
122
129
|
- lib/phlex/reactive.rb
|
|
123
130
|
- lib/phlex/reactive/component.rb
|
|
@@ -146,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
146
153
|
- !ruby/object:Gem::Version
|
|
147
154
|
version: '0'
|
|
148
155
|
requirements: []
|
|
149
|
-
rubygems_version:
|
|
156
|
+
rubygems_version: 3.6.9
|
|
150
157
|
specification_version: 4
|
|
151
158
|
summary: Reactive Phlex components for Rails — Livewire-style actions and live cross-tab
|
|
152
159
|
updates, no Stimulus boilerplate.
|