phlex-reactive 0.2.6 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6db6575334b8d59a054812e6815dc9b1968274a7917c66ec48c353dd95ba477d
4
- data.tar.gz: 294d2d892c5e8a4b55466e0102b89fe945f7b4f23e1a882b06566c6898520f03
3
+ metadata.gz: 479ff63041e0a402939303aec9ea5fc8064dc0b01f5e63c8c58cfe989e5173b3
4
+ data.tar.gz: b81323095162d6b0662ff18b91b0c8cb34a8e2000028353c695e255ffa13093f
5
5
  SHA512:
6
- metadata.gz: 362f900e12b5d4d0e3240d230b64b7ea1366e9109c1957c266d7695a7f3985b762ce29843c644c12dba6e91ffead0bf6ff7f7e73dc32eac046de79c5c5e3c8cc
7
- data.tar.gz: 3fc968f66ea7892eba20e050e5be95ad75c878a5518052eaef4006a86b963ac3a3fd1e155d1d4b45f0ea05602d3db1decae1fbb3c75e3bd76cc93bc66a29a673
6
+ metadata.gz: 80070c0e2868530f8499008a603e6f32c4815916fa34c4fccc050b3fb0772c0501ea27e569ee88ada9acb68d7f3eca5fc7333201fc3b9e4358780abd9b6e5d6a
7
+ data.tar.gz: 6412dc6000663fd888193bf9a1b071b9d2f1984692cac474217ebd4955f302e963103bb4bd5ad5c3407b351dfe69a5c2a7079fcdd04b65d73dfed34092c60765
data/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ 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
+
9
43
  ## [0.2.6]
10
44
 
11
45
  ### Added
data/README.md CHANGED
@@ -268,10 +268,50 @@ 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
+ **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
+
275
315
  **Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
276
316
  a hash that includes a `data:` key. Spreading them *and* passing another `data:`
277
317
  (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
- raw = params.fetch(:params, {})
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 raw.key?(key.to_s)
173
+ next unless hash.key?(key.to_s)
132
174
 
133
- out[key.to_sym] = coerce(raw[key.to_s], type)
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 coerce(value, type)
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,27 @@ 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.
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
+
146
212
  # Only components that opt into Reactive may be resolved. The signature
147
213
  # already gates this; defense in depth against constant injection.
148
214
  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
- # Param types: :string (default), :integer, :float, :boolean.
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
- def on(action_name, event: "click", **params)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.2.6"
5
+ VERSION = "0.2.7"
6
6
  end
7
7
  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.6
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson