phlex-reactive 0.2.6 → 0.2.8
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 +46 -0
- data/README.md +52 -0
- data/app/controllers/phlex/reactive/actions_controller.rb +127 -4
- data/app/javascript/phlex/reactive/reactive_controller.js +66 -3
- data/lib/phlex/reactive/component.rb +24 -2
- data/lib/phlex/reactive/version.rb +1 -1
- 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: 9b321b7411a892ec63333f9d85d988f7cf63609601386a03593efd736e88fb42
|
|
4
|
+
data.tar.gz: 00d40c394774eb54af634d328251f646a3fae1ac9bc0413ccbc89dd863608001
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd7a2129ec09c6b33a9f623f201037f5bc6639f7b097c95f521a6bf7ba4999b8dc8e11f53f2b922f5e617d0dfd8e2b0185cc5676e20f040a9caf97637d2d9438
|
|
7
|
+
data.tar.gz: b6e611eab9a2ab3f3d3a6afff9c4780061ba07321ddb5db89970e076facc9d7a374f7809fe8bc38958040c34303a42484eefe25109495bdfb2b6f894a597ae3e
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,52 @@ 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
|
+
- **Model-scoped form fields feed a nested param (issue #21).** A Rails
|
|
35
|
+
`Form(model: @invoice)` posts flat bracketed keys (`invoice[date]`,
|
|
36
|
+
`invoice[status]`) because the client keeps each input's `name` verbatim. The
|
|
37
|
+
server did exact-key matching, so a nested schema (`params: { invoice: { date:
|
|
38
|
+
:string } }`) looked for the literal key `"invoice"`, never found it, and
|
|
39
|
+
dropped the whole param. Param normalization now expands bracket notation
|
|
40
|
+
before coercion — `invoice[date]` nests under `invoice`, and
|
|
41
|
+
`items_attributes[0][qty]` becomes the Rails index-hash form the array coercer
|
|
42
|
+
already understands. A nested schema matches a normal Rails form with zero
|
|
43
|
+
field renaming, which is what makes the issue #16 nested-param types useful for
|
|
44
|
+
real forms. Pre-nested objects, plain scalars, and non-string values (a
|
|
45
|
+
checkbox boolean) pass through unchanged.
|
|
46
|
+
- **Nested reactive roots no longer leak fields (issue #15).** When a reactive
|
|
47
|
+
component is rendered inside another (both are `data-controller="reactive"`
|
|
48
|
+
roots), an action on the outer root previously swept *every* descendant named
|
|
49
|
+
input — including the nested roots' inputs — into its own params. Field
|
|
50
|
+
collection now stops at nested reactive roots: an action collects only the
|
|
51
|
+
inputs whose nearest `[data-controller~="reactive"]` ancestor is its own root.
|
|
52
|
+
Outer flat fields and per-row reactive editing compose cleanly, with no
|
|
53
|
+
name-disjointness workarounds.
|
|
54
|
+
|
|
9
55
|
## [0.2.6]
|
|
10
56
|
|
|
11
57
|
### Added
|
data/README.md
CHANGED
|
@@ -268,10 +268,62 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
268
268
|
| `action :name, params: { x: :integer }` | Declare a client-invokable action + its param schema. **Default-deny.** |
|
|
269
269
|
| `reactive_attrs` | Spread onto the root element: marks it reactive + carries the signed token. |
|
|
270
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). |
|
|
271
272
|
|
|
272
273
|
Param types: `:string` (default), `:integer`, `:float`, `:boolean`. Anything not
|
|
273
274
|
in the schema is dropped before reaching your method.
|
|
274
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
|
+
**Model-scoped form fields just work.** A standard Rails `Form(model: @invoice)`
|
|
298
|
+
names its inputs `invoice[date]`, `invoice[status]`, … and the client posts those
|
|
299
|
+
names verbatim. A nested schema matches them with zero field renaming — the
|
|
300
|
+
endpoint expands bracket notation before coercion, so `invoice[date]` nests under
|
|
301
|
+
`invoice` and `invoice_items_attributes[0][qty]` becomes the index-hash form
|
|
302
|
+
above:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
action :save, params: {invoice: {date: :string, status: :string}}
|
|
306
|
+
# client posts { "invoice[date]": "…", "invoice[status]": "…" } → save(invoice: { date:, status: })
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Nested reactive components compose.** A reactive component rendered inside
|
|
310
|
+
another is its own root — field collection stops at nested
|
|
311
|
+
`data-controller="reactive"` roots, so an outer action collects only *its own*
|
|
312
|
+
named inputs, never a nested component's. An invoice editor's `save` sees its
|
|
313
|
+
flat fields; each line-item row's `quantity`/`price` belong to that row's own
|
|
314
|
+
action. No name-disjointness workarounds required.
|
|
315
|
+
|
|
316
|
+
**Debounced triggers (live-as-you-type).** Pass `debounce:` (milliseconds) to
|
|
317
|
+
coalesce rapid events — typically keystrokes on an `"input"` trigger — into a
|
|
318
|
+
single action round trip fired after the quiet period, instead of one POST per
|
|
319
|
+
keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
|
|
320
|
+
Omit `debounce:` for the immediate-dispatch default.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# Recompute a total live as the user types, without hammering the endpoint.
|
|
324
|
+
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
|
|
325
|
+
```
|
|
326
|
+
|
|
275
327
|
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
276
328
|
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
277
329
|
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge:
|
|
@@ -123,18 +123,63 @@ module Phlex
|
|
|
123
123
|
|
|
124
124
|
# Coerce client params against the action's declared schema. Anything not
|
|
125
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).
|
|
126
128
|
def coerce_params(schema)
|
|
127
129
|
return {} if schema.blank?
|
|
128
130
|
|
|
129
|
-
|
|
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)
|
|
130
172
|
schema.each_with_object({}) do |(key, type), out|
|
|
131
|
-
next unless
|
|
173
|
+
next unless hash.key?(key.to_s)
|
|
132
174
|
|
|
133
|
-
|
|
175
|
+
coerced = coerce(hash[key.to_s], type)
|
|
176
|
+
next if coerced.equal?(DROP)
|
|
177
|
+
|
|
178
|
+
out[key.to_sym] = coerced
|
|
134
179
|
end
|
|
135
180
|
end
|
|
136
181
|
|
|
137
|
-
def
|
|
182
|
+
def coerce_scalar(value, type)
|
|
138
183
|
case type
|
|
139
184
|
when :integer then value.to_i
|
|
140
185
|
when :float then value.to_f
|
|
@@ -143,6 +188,84 @@ module Phlex
|
|
|
143
188
|
end
|
|
144
189
|
end
|
|
145
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, then expand bracket notation so
|
|
205
|
+
# a model-scoped Rails form's flat keys nest (issue #21).
|
|
206
|
+
def to_param_hash(value)
|
|
207
|
+
flat =
|
|
208
|
+
if value.respond_to?(:to_unsafe_h)
|
|
209
|
+
value.to_unsafe_h.stringify_keys
|
|
210
|
+
elsif value.is_a?(Hash)
|
|
211
|
+
value.stringify_keys
|
|
212
|
+
else
|
|
213
|
+
return {}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
expand_bracket_keys(flat)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The client's #collectFields keeps a form input's name verbatim, so a
|
|
220
|
+
# Rails Form(model: @invoice) posts FLAT bracketed keys like
|
|
221
|
+
# "invoice[date]". Coercion does exact-key matching, so without this a
|
|
222
|
+
# nested schema (params: { invoice: { date: … } }) never finds "invoice"
|
|
223
|
+
# and drops everything. Expand "invoice[date]" => "2026-01-02" into
|
|
224
|
+
# { "invoice" => { "date" => "2026-01-02" } } — and "items[0][qty]" into
|
|
225
|
+
# the Rails index-hash form coerce_array already understands — deep-merging
|
|
226
|
+
# so sibling keys (invoice[date], invoice[status]) coalesce. Keys WITHOUT
|
|
227
|
+
# brackets and already-nested values pass through untouched, so a
|
|
228
|
+
# pre-nested object (issue #16) and plain scalars still work. Value types
|
|
229
|
+
# (a checkbox boolean, an explicit array) are preserved verbatim — unlike a
|
|
230
|
+
# round-trip through parse_nested_query, which only handles strings.
|
|
231
|
+
def expand_bracket_keys(flat)
|
|
232
|
+
flat.each_with_object({}) do |(key, value), out|
|
|
233
|
+
deep_assign(out, bracket_path(key), value)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# "invoice[items_attributes][0][qty]" => ["invoice", "items_attributes",
|
|
238
|
+
# "0", "qty"]. A key with no brackets is a single-element path.
|
|
239
|
+
def bracket_path(key)
|
|
240
|
+
return [key] unless key.include?("[")
|
|
241
|
+
|
|
242
|
+
head, rest = key.split("[", 2)
|
|
243
|
+
[head, *rest.scan(/[^\[\]]+/)]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Walk/create nested hashes along `path`, then merge `value` at the leaf so
|
|
247
|
+
# a bracket key and a sibling pre-nested object coalesce regardless of which
|
|
248
|
+
# arrives first ({ "invoice[date]" => …, invoice: { status: … } } keeps
|
|
249
|
+
# both). #merge_value deep-merges hash/hash collisions and otherwise lets
|
|
250
|
+
# the later value win (a bracket key colliding with a flat scalar).
|
|
251
|
+
def deep_assign(hash, path, value)
|
|
252
|
+
*parents, leaf = path
|
|
253
|
+
node = parents.reduce(hash) do |acc, segment|
|
|
254
|
+
acc[segment] = {} unless acc[segment].is_a?(Hash)
|
|
255
|
+
acc[segment]
|
|
256
|
+
end
|
|
257
|
+
node[leaf] = merge_value(node[leaf], value)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Combine an existing leaf value with a new one. Two hashes deep-merge (so
|
|
261
|
+
# bracket-expanded fields and a pre-nested object for the same key both
|
|
262
|
+
# survive); any other collision takes the new value.
|
|
263
|
+
def merge_value(existing, value)
|
|
264
|
+
return value unless existing.is_a?(Hash) && value.is_a?(Hash)
|
|
265
|
+
|
|
266
|
+
existing.merge(value.stringify_keys) { |_k, old, new| merge_value(old, new) }
|
|
267
|
+
end
|
|
268
|
+
|
|
146
269
|
# Only components that opt into Reactive may be resolved. The signature
|
|
147
270
|
# already gates this; defense in depth against constant injection.
|
|
148
271
|
def resolve_component(name)
|
|
@@ -45,6 +45,15 @@ export default class extends Controller {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
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
|
+
}
|
|
48
57
|
|
|
49
58
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
50
59
|
// token in the DOM (state lives in the token, not the client). If events
|
|
@@ -53,7 +62,7 @@ export default class extends Controller {
|
|
|
53
62
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
54
63
|
// it always uses the freshest token.
|
|
55
64
|
dispatch(event) {
|
|
56
|
-
const { action, params } = event.params
|
|
65
|
+
const { action, params, debounce } = event.params
|
|
57
66
|
if (!action) return
|
|
58
67
|
|
|
59
68
|
// Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
|
|
@@ -61,15 +70,57 @@ export default class extends Controller {
|
|
|
61
70
|
// still being handled — deferring it into the request-queue microtask (below)
|
|
62
71
|
// is too late: a `submit` trigger would natively POST the form and navigate
|
|
63
72
|
// before the reactive round trip runs (issue #11). For a `click` trigger
|
|
64
|
-
// 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.
|
|
65
76
|
event.preventDefault()
|
|
66
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
|
+
|
|
67
84
|
// Capture action/params now; the queued work runs in a later microtask, by
|
|
68
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) {
|
|
69
90
|
this.queue = (this.queue ?? Promise.resolve()).then(() => this.#perform(action, params))
|
|
70
91
|
return this.queue
|
|
71
92
|
}
|
|
72
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
|
+
|
|
73
124
|
async #perform(action, params) {
|
|
74
125
|
// Auto-collect named field values inside this component so a button-
|
|
75
126
|
// triggered action still receives sibling inputs (Livewire-style).
|
|
@@ -145,10 +196,21 @@ export default class extends Controller {
|
|
|
145
196
|
return match?.[1]
|
|
146
197
|
}
|
|
147
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
|
+
|
|
148
209
|
#collectFields() {
|
|
149
210
|
const fields = {}
|
|
150
|
-
// Standard form controls.
|
|
211
|
+
// Standard form controls owned by THIS root (not a nested reactive root).
|
|
151
212
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
213
|
+
if (!this.#ownsField(field)) return
|
|
152
214
|
if (field.type === "checkbox") {
|
|
153
215
|
fields[field.name] = field.checked
|
|
154
216
|
} else if (field.type === "radio") {
|
|
@@ -167,6 +229,7 @@ export default class extends Controller {
|
|
|
167
229
|
this.element
|
|
168
230
|
.querySelectorAll("[name]:is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])")
|
|
169
231
|
.forEach((el) => {
|
|
232
|
+
if (!this.#ownsField(el)) return // skip editors owned by a nested reactive root (issue #15)
|
|
170
233
|
// A plain element (e.g. a <div contenteditable>) has no `name` IDL
|
|
171
234
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
172
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
|