phlex-reactive 0.2.5 → 0.2.7
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 +63 -0
- data/README.md +78 -2
- data/app/controllers/phlex/reactive/actions_controller.rb +105 -6
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -3
- data/lib/phlex/reactive/component.rb +24 -2
- 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: 479ff63041e0a402939303aec9ea5fc8064dc0b01f5e63c8c58cfe989e5173b3
|
|
4
|
+
data.tar.gz: b81323095162d6b0662ff18b91b0c8cb34a8e2000028353c695e255ffa13093f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80070c0e2868530f8499008a603e6f32c4815916fa34c4fccc050b3fb0772c0501ea27e569ee88ada9acb68d7f3eca5fc7333201fc3b9e4358780abd9b6e5d6a
|
|
7
|
+
data.tar.gz: 6412dc6000663fd888193bf9a1b071b9d2f1984692cac474217ebd4955f302e963103bb4bd5ad5c3407b351dfe69a5c2a7079fcdd04b65d73dfed34092c60765
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,69 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Nested & array param types (issue #16).** Action param schemas can now
|
|
12
|
+
declare arrays and nested hashes, not just scalars: wrap a type in an array
|
|
13
|
+
(`bank_account_ids: [:integer]`) for an array param, or wrap a hash schema in
|
|
14
|
+
an array (`invoice_items_attributes: [{ id: :integer, quantity: :float,
|
|
15
|
+
_destroy: :boolean }]`) for Rails-style nested attributes. Coercion recurses
|
|
16
|
+
per field, drops undeclared nested keys (no mass assignment), and accepts an
|
|
17
|
+
array as either a JSON array or a Rails index hash (`{ "0" => …, "1" => … }`).
|
|
18
|
+
A malformed (present-but-non-array) value for an array param is dropped — not
|
|
19
|
+
coerced to `[]` — so a bad payload can't read as an explicit "clear all" on an
|
|
20
|
+
`update!(declared_array:)`; a real empty array still passes through as `[]`.
|
|
21
|
+
A reactive form can now mirror a normal nested-attributes update in one action
|
|
22
|
+
instead of being forced into a per-row component architecture.
|
|
23
|
+
- **Debounce option on `on(...)` (issue #17).** A trigger can declare
|
|
24
|
+
`on(:update, event: "input", debounce: 300)` (milliseconds) to coalesce rapid
|
|
25
|
+
events — typically keystrokes — into a SINGLE action round trip fired after the
|
|
26
|
+
quiet period, instead of one POST per keystroke. A `blur` flushes a pending
|
|
27
|
+
dispatch so the last edit is never dropped, `preventDefault` still fires
|
|
28
|
+
synchronously (a debounced `submit` won't navigate), and the debounced round
|
|
29
|
+
trip still goes through the per-component queue so token threading holds.
|
|
30
|
+
Omitting `debounce:` keeps the immediate-dispatch default.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **Nested reactive roots no longer leak fields (issue #15).** When a reactive
|
|
35
|
+
component is rendered inside another (both are `data-controller="reactive"`
|
|
36
|
+
roots), an action on the outer root previously swept *every* descendant named
|
|
37
|
+
input — including the nested roots' inputs — into its own params. Field
|
|
38
|
+
collection now stops at nested reactive roots: an action collects only the
|
|
39
|
+
inputs whose nearest `[data-controller~="reactive"]` ancestor is its own root.
|
|
40
|
+
Outer flat fields and per-row reactive editing compose cleanly, with no
|
|
41
|
+
name-disjointness workarounds.
|
|
42
|
+
|
|
43
|
+
## [0.2.6]
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **Action response control via `Phlex::Reactive::Response`.** An action MAY now
|
|
48
|
+
return a `Response` to govern the actor's HTTP reply, instead of only the
|
|
49
|
+
implicit single re-render. Returning anything else keeps the legacy default
|
|
50
|
+
(re-render the component in place), so existing actions are unaffected.
|
|
51
|
+
- `Response.replace(self)` / `.update(self)` — explicit re-render.
|
|
52
|
+
- `Response.replace(self).flash(:error, msg_or_component)` — surface a
|
|
53
|
+
validation error / notice (the `#1` driver). `.flash` is additive on a
|
|
54
|
+
self-replace, so the component's signed token always refreshes. Flash
|
|
55
|
+
content is supplied explicitly (the render context is off-request — there is
|
|
56
|
+
no Rails `flash`); pass a string or a Phlex component. Target container is
|
|
57
|
+
`Phlex::Reactive.flash_target` (default `flash`).
|
|
58
|
+
- `Response.remove(self)` — drop the element (e.g. a moderation queue). New
|
|
59
|
+
instance helper `Streamable#to_stream_remove` backs it.
|
|
60
|
+
- `Response.redirect(url)` — client-side `Turbo.visit` for when the current
|
|
61
|
+
URL is dead (e.g. a slug rename). Rides a 200 turbo-stream carrying a
|
|
62
|
+
`reactive:visit` custom action (registered in the client), **not** an HTTP
|
|
63
|
+
3xx (which the client bails on). Pass a `*_url`.
|
|
64
|
+
- `Response.with(*streams)` / `#stream(*more)` — multi-stream (replace self +
|
|
65
|
+
a sibling component).
|
|
66
|
+
- The endpoint guarantees the component's own replace is present (token refresh)
|
|
67
|
+
for non-remove/redirect responses, and never double-prepends when the action
|
|
68
|
+
already included a self-targeted stream.
|
|
69
|
+
|
|
70
|
+
## [0.2.5]
|
|
71
|
+
|
|
9
72
|
### Fixed
|
|
10
73
|
|
|
11
74
|
- **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
|
|
|
@@ -267,10 +268,50 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
267
268
|
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
268
269
|
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
269
270
|
| `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
|
|
271
|
+
| `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
|
|
270
272
|
|
|
271
273
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
272
274
|
in the schema is dropped before reaching your method.
|
|
273
275
|
|
|
276
|
+
**Array & nested params.** Wrap a type in an array for an array param, or a hash
|
|
277
|
+
schema in an array for Rails-style nested attributes — so one reactive action can
|
|
278
|
+
mirror a normal nested-attributes update instead of forcing a per-row component:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
action :save, params: {
|
|
282
|
+
date: :string,
|
|
283
|
+
bank_account_ids: [:integer], # array of scalar
|
|
284
|
+
invoice_items_attributes: [ # array of hash
|
|
285
|
+
{ id: :integer, quantity: :float, price: :float, _destroy: :boolean }
|
|
286
|
+
]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
def save(date:, bank_account_ids:, invoice_items_attributes:)
|
|
290
|
+
@invoice.update!(date:, bank_account_ids:, invoice_items_attributes:)
|
|
291
|
+
end
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Nested coercion recurses per field, drops undeclared nested keys, and accepts an
|
|
295
|
+
array as either a JSON array or a Rails index hash (`{ "0" => …, "1" => … }`).
|
|
296
|
+
|
|
297
|
+
**Nested reactive components compose.** A reactive component rendered inside
|
|
298
|
+
another is its own root — field collection stops at nested
|
|
299
|
+
`data-controller="reactive"` roots, so an outer action collects only *its own*
|
|
300
|
+
named inputs, never a nested component's. An invoice editor's `save` sees its
|
|
301
|
+
flat fields; each line-item row's `quantity`/`price` belong to that row's own
|
|
302
|
+
action. No name-disjointness workarounds required.
|
|
303
|
+
|
|
304
|
+
**Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to
|
|
305
|
+
coalesce rapid events — typically keystrokes on an `"input"` trigger — into a
|
|
306
|
+
single action round trip fired after the quiet period, instead of one POST per
|
|
307
|
+
keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
|
|
308
|
+
Omit `debounce:` for the immediate-dispatch default.
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
# Recompute a total live as the user types, without hammering the endpoint.
|
|
312
|
+
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
|
|
313
|
+
```
|
|
314
|
+
|
|
274
315
|
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
275
316
|
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
276
317
|
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
|
|
@@ -284,6 +325,41 @@ div(**mix(reactive_attrs, id:, class: "card")) { ... }
|
|
|
284
325
|
button(**on(:increment), data: { testid: "inc" }) { "+" }
|
|
285
326
|
```
|
|
286
327
|
|
|
328
|
+
### `Phlex::Reactive::Response` — controlling the action's reply
|
|
329
|
+
|
|
330
|
+
By default an action re-renders its component in place. **Return** a
|
|
331
|
+
`Phlex::Reactive::Response` to do more (it governs only the actor's HTTP reply —
|
|
332
|
+
cross-tab updates still use `broadcast_*_to(..., exclude: reactive_connection_id)`).
|
|
333
|
+
Returning anything else keeps the default, so existing actions are unaffected.
|
|
334
|
+
|
|
335
|
+
The snippets below alias the constant for brevity (`Response.replace(self)` won't
|
|
336
|
+
resolve to `Phlex::Reactive::Response` inside a namespaced component — fully
|
|
337
|
+
qualify it, or add the alias shown):
|
|
338
|
+
|
|
339
|
+
```ruby
|
|
340
|
+
Response = Phlex::Reactive::Response # or qualify each call below
|
|
341
|
+
|
|
342
|
+
def rename(title:)
|
|
343
|
+
return Response.replace(self).flash(:error, @todo.errors.full_messages.to_sentence) unless @todo.update(title:)
|
|
344
|
+
Response.replace(self)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def approve = (@row.approve!; Response.remove(self)) # drop the element
|
|
348
|
+
def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed → Turbo.visit
|
|
349
|
+
def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
| Builder | Reply |
|
|
353
|
+
|---|---|
|
|
354
|
+
| `Response.replace(self)` / `.update(self)` | re-render in place (explicit default) |
|
|
355
|
+
| `.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"`) |
|
|
356
|
+
| `Response.remove(self)` | remove the element (backed by `Streamable#to_stream_remove`) |
|
|
357
|
+
| `Response.redirect(url)` | client-side `Turbo.visit` (pass a `*_url`); rides a `reactive:visit` turbo-stream, not an HTTP 3xx |
|
|
358
|
+
| `Response.with(*streams)` / `#stream(*more)` | multi-stream |
|
|
359
|
+
|
|
360
|
+
`.flash`/`.stream` are additive on a self-replace, so the component's signed
|
|
361
|
+
token always refreshes.
|
|
362
|
+
|
|
287
363
|
### Configuration (`config/initializers/phlex_reactive.rb`)
|
|
288
364
|
|
|
289
365
|
```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)
|
|
@@ -90,18 +123,63 @@ module Phlex
|
|
|
90
123
|
|
|
91
124
|
# Coerce client params against the action's declared schema. Anything not
|
|
92
125
|
# in the schema is dropped — no raw mass assignment reaches the component.
|
|
126
|
+
# The top-level params arrive as an ActionController::Parameters; coerce
|
|
127
|
+
# them against the action's hash schema (same recursion as nested hashes).
|
|
93
128
|
def coerce_params(schema)
|
|
94
129
|
return {} if schema.blank?
|
|
95
130
|
|
|
96
|
-
|
|
131
|
+
coerce_hash(params.fetch(:params, {}), schema)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Sentinel: a declared key whose value can't be coerced to its type is
|
|
135
|
+
# DROPPED (not assigned), so the method's keyword default applies — exactly
|
|
136
|
+
# as if the client had omitted the key. Distinct from a coerced nil/[].
|
|
137
|
+
DROP = Object.new
|
|
138
|
+
private_constant :DROP
|
|
139
|
+
|
|
140
|
+
# Coerce a value against a declared type. A type is one of:
|
|
141
|
+
# * a scalar symbol (:string/:integer/:float/:boolean)
|
|
142
|
+
# * a Hash schema ({ id: :integer, ... }) — nested object
|
|
143
|
+
# * a one-element Array ([:integer] / [{ ... }]) — array of that
|
|
144
|
+
# Arrays accept both a real JSON array and a Rails-style index hash
|
|
145
|
+
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
146
|
+
def coerce(value, type)
|
|
147
|
+
if type.is_a?(Array)
|
|
148
|
+
coerce_array(value, type.first)
|
|
149
|
+
elsif type.is_a?(Hash)
|
|
150
|
+
coerce_hash(value, type)
|
|
151
|
+
else
|
|
152
|
+
coerce_scalar(value, type)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A real array (or Rails index hash) coerces element-wise. A malformed
|
|
157
|
+
# present-but-non-array value returns DROP rather than [] — coercing a stray
|
|
158
|
+
# scalar to an empty array would let a bad payload read as an explicit
|
|
159
|
+
# "clear everything" on update!(declared_array:).
|
|
160
|
+
def coerce_array(value, element_type)
|
|
161
|
+
values = array_values(value)
|
|
162
|
+
return DROP if values.nil?
|
|
163
|
+
|
|
164
|
+
values.map { |element| coerce(element, element_type) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Keep declared keys only (drop undeclared — no mass assignment), recursing
|
|
168
|
+
# for nested hash/array element types. Symbolizes keys to splat as kwargs.
|
|
169
|
+
# A key whose value coerces to DROP is skipped (keyword default applies).
|
|
170
|
+
def coerce_hash(value, schema)
|
|
171
|
+
hash = to_param_hash(value)
|
|
97
172
|
schema.each_with_object({}) do |(key, type), out|
|
|
98
|
-
next unless
|
|
173
|
+
next unless hash.key?(key.to_s)
|
|
99
174
|
|
|
100
|
-
|
|
175
|
+
coerced = coerce(hash[key.to_s], type)
|
|
176
|
+
next if coerced.equal?(DROP)
|
|
177
|
+
|
|
178
|
+
out[key.to_sym] = coerced
|
|
101
179
|
end
|
|
102
180
|
end
|
|
103
181
|
|
|
104
|
-
def
|
|
182
|
+
def coerce_scalar(value, type)
|
|
105
183
|
case type
|
|
106
184
|
when :integer then value.to_i
|
|
107
185
|
when :float then value.to_f
|
|
@@ -110,6 +188,27 @@ module Phlex
|
|
|
110
188
|
end
|
|
111
189
|
end
|
|
112
190
|
|
|
191
|
+
# Normalize an array param: a real array passes through; a Rails index hash
|
|
192
|
+
# ({ "0" => ..., "1" => ... }) becomes its values in index order. Anything
|
|
193
|
+
# else (a stray scalar, nil) is malformed → nil, so the caller drops the
|
|
194
|
+
# param rather than fabricating an empty array.
|
|
195
|
+
def array_values(value)
|
|
196
|
+
return value.to_a if value.is_a?(Array)
|
|
197
|
+
|
|
198
|
+
if value.respond_to?(:to_unsafe_h) || value.is_a?(Hash)
|
|
199
|
+
to_param_hash(value).sort_by { |k, _| k.to_i }.map(&:last)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Unwrap ActionController::Parameters (or a plain Hash) to a string-keyed
|
|
204
|
+
# Hash so coercion can index it uniformly.
|
|
205
|
+
def to_param_hash(value)
|
|
206
|
+
return value.to_unsafe_h.stringify_keys if value.respond_to?(:to_unsafe_h)
|
|
207
|
+
return value.stringify_keys if value.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
{}
|
|
210
|
+
end
|
|
211
|
+
|
|
113
212
|
# Only components that opt into Reactive may be resolved. The signature
|
|
114
213
|
# already gates this; defense in depth against constant injection.
|
|
115
214
|
def resolve_component(name)
|
|
@@ -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.
|
|
@@ -26,6 +45,15 @@ export default class extends Controller {
|
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
48
|
+
#debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
|
|
49
|
+
|
|
50
|
+
// Tear down any pending debounce timers when the controller leaves the DOM
|
|
51
|
+
// (Turbo morph/navigation removes the element). Otherwise a timer that hasn't
|
|
52
|
+
// fired yet would later call #enqueue on a disconnected controller — a round
|
|
53
|
+
// trip against a detached element / stale token (issue #17 follow-up).
|
|
54
|
+
disconnect() {
|
|
55
|
+
this.#clearAllDebounces()
|
|
56
|
+
}
|
|
29
57
|
|
|
30
58
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
31
59
|
// token in the DOM (state lives in the token, not the client). If events
|
|
@@ -34,7 +62,7 @@ export default class extends Controller {
|
|
|
34
62
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
35
63
|
// it always uses the freshest token.
|
|
36
64
|
dispatch(event) {
|
|
37
|
-
const { action, params } = event.params
|
|
65
|
+
const { action, params, debounce } = event.params
|
|
38
66
|
if (!action) return
|
|
39
67
|
|
|
40
68
|
// Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
|
|
@@ -42,15 +70,57 @@ export default class extends Controller {
|
|
|
42
70
|
// still being handled — deferring it into the request-queue microtask (below)
|
|
43
71
|
// is too late: a `submit` trigger would natively POST the form and navigate
|
|
44
72
|
// before the reactive round trip runs (issue #11). For a `click` trigger
|
|
45
|
-
// there's no default to miss, so this was previously invisible.
|
|
73
|
+
// there's no default to miss, so this was previously invisible. This holds
|
|
74
|
+
// for debounced triggers too — the round trip is deferred, but the native
|
|
75
|
+
// default must still be prevented now.
|
|
46
76
|
event.preventDefault()
|
|
47
77
|
|
|
78
|
+
// Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
|
|
79
|
+
// coalesce rapid events into ONE round trip after a quiet period, instead of
|
|
80
|
+
// one POST per keystroke (issue #17). A blur flushes a pending dispatch.
|
|
81
|
+
const ms = Number(debounce) || 0
|
|
82
|
+
if (ms > 0) return this.#debounceDispatch(event.target, ms, action, params)
|
|
83
|
+
|
|
48
84
|
// Capture action/params now; the queued work runs in a later microtask, by
|
|
49
85
|
// which point the event object may have been reset by the browser.
|
|
86
|
+
return this.#enqueue(action, params)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#enqueue(action, params) {
|
|
50
90
|
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params))
|
|
51
91
|
return this.queue
|
|
52
92
|
}
|
|
53
93
|
|
|
94
|
+
// Reset a per-element timer; only enqueue the round trip after `ms` of quiet.
|
|
95
|
+
// Also flush immediately on blur so leaving the field never drops the last
|
|
96
|
+
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
97
|
+
#debounceDispatch(target, ms, action, params) {
|
|
98
|
+
this.#clearDebounce(target)
|
|
99
|
+
|
|
100
|
+
const flush = () => {
|
|
101
|
+
this.#clearDebounce(target)
|
|
102
|
+
this.#enqueue(action, params)
|
|
103
|
+
}
|
|
104
|
+
const timer = setTimeout(flush, ms)
|
|
105
|
+
target?.addEventListener?.("blur", flush, { once: true })
|
|
106
|
+
this.#debounceTimers.set(target, { timer, flush })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#clearDebounce(target) {
|
|
110
|
+
const pending = this.#debounceTimers.get(target)
|
|
111
|
+
if (!pending) return
|
|
112
|
+
clearTimeout(pending.timer)
|
|
113
|
+
target?.removeEventListener?.("blur", pending.flush)
|
|
114
|
+
this.#debounceTimers.delete(target)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clear every pending debounce timer (used on disconnect). Reuses
|
|
118
|
+
// #clearDebounce so all timer/listener teardown stays in one place. Snapshot
|
|
119
|
+
// the keys first — #clearDebounce mutates the map as it goes.
|
|
120
|
+
#clearAllDebounces() {
|
|
121
|
+
for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
|
|
122
|
+
}
|
|
123
|
+
|
|
54
124
|
async #perform(action, params) {
|
|
55
125
|
// Auto-collect named field values inside this component so a button-
|
|
56
126
|
// triggered action still receives sibling inputs (Livewire-style).
|
|
@@ -126,10 +196,21 @@ export default class extends Controller {
|
|
|
126
196
|
return match?.[1]
|
|
127
197
|
}
|
|
128
198
|
|
|
199
|
+
// True when `el` is collected by THIS reactive root and not by a nested one.
|
|
200
|
+
// A reactive component can be rendered inside another (both are
|
|
201
|
+
// data-controller="reactive" roots). querySelectorAll() descends into nested
|
|
202
|
+
// roots, so without this guard an outer action would sweep the inner roots'
|
|
203
|
+
// inputs into its own params (issue #15). An element belongs to this root iff
|
|
204
|
+
// its nearest [data-controller~="reactive"] ancestor is this.element.
|
|
205
|
+
#ownsField(el) {
|
|
206
|
+
return el.closest('[data-controller~="reactive"]') === this.element
|
|
207
|
+
}
|
|
208
|
+
|
|
129
209
|
#collectFields() {
|
|
130
210
|
const fields = {}
|
|
131
|
-
// Standard form controls.
|
|
211
|
+
// Standard form controls owned by THIS root (not a nested reactive root).
|
|
132
212
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
213
|
+
if (!this.#ownsField(field)) return
|
|
133
214
|
if (field.type === "checkbox") {
|
|
134
215
|
fields[field.name] = field.checked
|
|
135
216
|
} else if (field.type === "radio") {
|
|
@@ -148,6 +229,7 @@ export default class extends Controller {
|
|
|
148
229
|
this.element
|
|
149
230
|
.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
|
|
150
231
|
.forEach((el) => {
|
|
232
|
+
if (!this.#ownsField(el)) return // skip editors owned by a nested reactive root (issue #15)
|
|
151
233
|
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
152
234
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
153
235
|
const name = el.getAttribute("name")
|
|
@@ -87,7 +87,21 @@ module Phlex
|
|
|
87
87
|
# Declare a client-invokable action with an optional param schema.
|
|
88
88
|
# action :increment
|
|
89
89
|
# action :rename, params: { title: :string }
|
|
90
|
-
#
|
|
90
|
+
#
|
|
91
|
+
# Param types are coerced server-side; anything not in the schema is
|
|
92
|
+
# dropped before reaching your method (no mass assignment):
|
|
93
|
+
# * Scalars — :string (default), :integer, :float, :boolean
|
|
94
|
+
# * Array of scalar — wrap the type in an array: [:integer]
|
|
95
|
+
# * Array of hash (Rails nested attributes) — wrap a hash schema:
|
|
96
|
+
# action :save, params: {
|
|
97
|
+
# date: :string,
|
|
98
|
+
# bank_account_ids: [:integer],
|
|
99
|
+
# invoice_items_attributes: [
|
|
100
|
+
# { id: :integer, quantity: :float, price: :float, _destroy: :boolean }
|
|
101
|
+
# ]
|
|
102
|
+
# }
|
|
103
|
+
# Array params accept BOTH a JSON array and a Rails-style index hash
|
|
104
|
+
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
91
105
|
def action(name, params: {})
|
|
92
106
|
reactive_actions[name.to_sym] = Action.new(name: name.to_sym, params: params)
|
|
93
107
|
end
|
|
@@ -168,11 +182,18 @@ module Phlex
|
|
|
168
182
|
# Attributes for an element that triggers an action.
|
|
169
183
|
# button(**on(:toggle)) { "○" }
|
|
170
184
|
# form(**on(:save, event: "submit")) { ... }
|
|
185
|
+
# input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
|
|
171
186
|
#
|
|
172
187
|
# Extra keyword args become explicit params merged over collected form
|
|
173
188
|
# fields. For click triggers we force type="button" so a bare button
|
|
174
189
|
# inside a <form> can't submit it and cause a full-page navigation.
|
|
175
|
-
|
|
190
|
+
#
|
|
191
|
+
# `debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an
|
|
192
|
+
# "input" trigger) into ONE round trip fired after the quiet period — so
|
|
193
|
+
# live-update-as-you-type doesn't POST per keystroke. A blur flushes a
|
|
194
|
+
# pending dispatch so the last edit is never dropped. Omit it for the
|
|
195
|
+
# immediate-dispatch default.
|
|
196
|
+
def on(action_name, event: "click", debounce: nil, **params)
|
|
176
197
|
attrs = {
|
|
177
198
|
data: {
|
|
178
199
|
action: "#{event}->reactive#dispatch",
|
|
@@ -180,6 +201,7 @@ module Phlex
|
|
|
180
201
|
reactive_params_param: params.to_json
|
|
181
202
|
}
|
|
182
203
|
}
|
|
204
|
+
attrs[:data][:reactive_debounce_param] = debounce if debounce
|
|
183
205
|
attrs[:type] = "button" if event == "click"
|
|
184
206
|
attrs
|
|
185
207
|
end
|
|
@@ -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.7
|
|
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
|