phlex-reactive 0.4.5 → 0.4.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: 781352f05403f22ccf974061e325f0066b9809c20de36a881279fcea424c55a5
4
- data.tar.gz: dfd1747be189e0c451f390f396d535aa5c36f7e0fd8d95f31a331a584537976c
3
+ metadata.gz: 6a9dfa70408b2db2a88337edfc41963ead03b74cd83d919888b9e3b9bbf5eb99
4
+ data.tar.gz: 3adb9e76db58f23fd80ebbbc9cb50b08768bbf3473ee5f83375d89b781eda454
5
5
  SHA512:
6
- metadata.gz: 14e5f543ebb18929407b21a90f6851fcbc685954d23c2ef9aa6a37ed770c85bdf7d477bb430799f81837d04a31154ea85343406e6767f128862fecec82aca401
7
- data.tar.gz: 55d32359caa8bb5aa7be4b8db1c55cdf58651b08361dd4bd5e87e4b7bf50dd4cdb32d3aaede40be35966e28cf994ff8047006c085f17fc9002bc26f9cc3cf4d7
6
+ metadata.gz: 3011d097ec4280eaac80d94d914287d9604a3413615875ac719100065ef20d694ac4025e2695483d782f0c99e5803027dc5d717d38a922f4d404f40d67d513d9
7
+ data.tar.gz: 35b1ec519c60fa7c2a4655b80ad2717d8eca2eb84f71f262ec67ca618b83582acbef81980ceb6125addfb6b47dc9dc5f732170291a507dd2246b1986ddb1097c
data/CHANGELOG.md CHANGED
@@ -8,6 +8,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ### Added
10
10
 
11
+ - **Overridable / async confirm resolver — reuse your themed dialog (#55).**
12
+ Follow-up to #52. The `confirm:` gate was hardcoded to the synchronous,
13
+ browser-native `window.confirm`, so a reactive trigger was the one interaction
14
+ a Hotwire app couldn't theme — every confirmable reactive action showed the
15
+ unstyled native chrome instead of the app's `Turbo.config.forms.confirm`
16
+ dialog. The client now resolves the confirmation through an overridable hook
17
+ (`confirmResolver`, set via `setConfirmResolver` from `phlex/reactive/confirm`),
18
+ defaulting to `window.confirm`. An app reuses its styled dialog in one line:
19
+ `setConfirmResolver((m) => window.Turbo.config.forms.confirm(m))`. The resolver
20
+ may be **async** — `dispatch` `preventDefault`s up front (preserving the #11
21
+ submit-trigger guarantee), then `await`s the resolver and enqueues only on a
22
+ truthy result; a falsy resolve or a rejection cancels the action and never
23
+ leaks an unhandled rejection. Unset, behavior is byte-for-byte identical to
24
+ 0.4.5 (sync native confirm, no dependency); the `confirm:` markup/`on(...)` API
25
+ is unchanged. README documents the one-line opt-in.
26
+
11
27
  - **`reactive_root` helper — the whole reactive root in one spread (#48).**
12
28
  `reactive_attrs` doesn't emit `id:`, so an app could put `id:` on a *child*
13
29
  element and leave the controller root's `id` empty — which silently re-opened
@@ -85,6 +101,27 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
85
101
 
86
102
  ### Fixed
87
103
 
104
+ - **`reactive_controller.js` used a relative `./confirm.js` import that 404'd under
105
+ importmap-rails + Propshaft — taking down every Stimulus controller on the page (#57).**
106
+ The #55 confirm resolver added `import { confirmResolver } from "./confirm.js"` to the
107
+ client controller. Under importmap + Propshaft the controller is served at its
108
+ **digested** URL, and a relative sibling import is left untouched (Propshaft's JS
109
+ compiler rewrites only `RAILS_ASSET_URL(...)`, and the import map resolves **only**
110
+ bare specifiers, never relative-resolved URLs). So the browser resolved `./confirm.js`
111
+ against the digested controller URL and requested an **undigested**
112
+ `/assets/phlex/reactive/confirm.js` → **404**. The throwing import meant
113
+ `reactive_controller.js` never evaluated, and in an app that eagerly registers it the
114
+ whole controllers entrypoint died — **every** Stimulus controller on the page stopped,
115
+ with no obvious link to phlex-reactive. The fix imports the **bare** specifier the
116
+ engine already pins (`phlex/reactive/confirm`), which resolves to the digested asset
117
+ through the import map and mirrors how the gem already expects apps to import the
118
+ module (`import { setConfirmResolver } from "phlex/reactive/confirm"`). Bundlers
119
+ (esbuild/webpack/bun) resolve the bare specifier the same way they already resolve
120
+ `phlex/reactive/reactive_controller`; the gem's bun JS suite resolves it via a new
121
+ `tsconfig.json` `paths` alias. Covered by a bun unit test (the bare import resolves,
122
+ and the source no longer carries the relative form). 0.4.5 was unaffected (inline
123
+ `window.confirm`, no relative import).
124
+
88
125
  - **Client mirror of #44: collections of *reactive* rows were STILL add-once-only in
89
126
  the browser — `#extractToken` read the FIRST token in the response, not this
90
127
  controller's own (#46).** The server fix in 0.4.2 (#44) made the `add` response
data/README.md CHANGED
@@ -311,6 +311,7 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
311
311
  | `reactive_attrs` | Marks an element reactive + carries the signed token (no `id`). Spread alongside `id:` on the **same** element: `div(id:, **reactive_attrs)`. Prefer `reactive_root`, which can't split them. |
312
312
  | `on(:action, event: "click", **params)` | Spread onto a trigger element. Adds `type=button` for clicks. |
313
313
  | `on(:action, event: "input", debounce: 300)` | Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
314
+ | `on(:action, confirm: "Sure?")` | Gate a destructive trigger behind a confirmation. Defaults to `window.confirm`; override the dialog with [`setConfirmResolver`](#custom-confirmation-dialogs-setconfirmresolver). |
314
315
  | `reactive_input(:param, **attrs)` / `reactive_select(:param, **attrs)` | Render a control already bound to an action param (no magic `name:`). |
315
316
  | `reactive_field(:param, **attrs)` | The attribute hash behind the above — spread onto any control. |
316
317
  | `nested_update!(:assoc, attrs)` | Map a nested param onto `<assoc>_attributes` with id preservation; update the record. |
@@ -478,6 +479,37 @@ end
478
479
  `nested_attributes(:address, address)` returns the id-merged hash without
479
480
  updating, if you need to combine it with other attributes.
480
481
 
482
+ ### Custom confirmation dialogs (`setConfirmResolver`)
483
+
484
+ `on(:action, confirm: "Really delete this?")` gates a destructive trigger behind
485
+ a confirmation. Because the reactive controller preempts the event (its own
486
+ `preventDefault` + POST), Hotwire's `data-turbo-confirm` — which routes through
487
+ `Turbo.config.forms.confirm` — never runs for a reactive trigger. So by default
488
+ the gate uses the browser-native `window.confirm` (synchronous, no dependency,
489
+ screen-reader friendly).
490
+
491
+ If your app already themes confirmations (the common Hotwire setup —
492
+ `Turbo.config.forms.confirm = (message) => Promise<boolean>`, backed by a styled
493
+ modal), reuse that exact dialog for reactive triggers with one line at boot:
494
+
495
+ ```js
496
+ import { setConfirmResolver } from "phlex/reactive/confirm"
497
+
498
+ // Reuse the same themed dialog the rest of the app already uses.
499
+ setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))
500
+ ```
501
+
502
+ The resolver receives the `confirm:` message and returns `true`/`false` (or a
503
+ `Promise` of one). It may be **async** — the controller `await`s it, then runs
504
+ the action only on a truthy result; a falsy result (or a rejected promise — e.g.
505
+ the user dismissed the dialog) cancels the action, exactly like declining the
506
+ native prompt. The native default is always prevented up front, so a `submit`
507
+ trigger never navigates while the dialog is open.
508
+
509
+ Unset, behavior is identical to the native `window.confirm` — the `confirm:`
510
+ markup and `on(...)` API are unchanged; only the client's resolution strategy
511
+ gains a seam.
512
+
481
513
  ### `reply` — controlling the action's reply
482
514
 
483
515
  By default an action re-renders its component in place. To do more, **return**
@@ -0,0 +1,34 @@
1
+ // The overridable confirmation resolver behind `on(:action, confirm: "…")`
2
+ // (issue #55, follow-up to #52).
3
+ //
4
+ // The reactive controller preempts the click event (its own preventDefault +
5
+ // POST), so Hotwire's `data-turbo-confirm` — which routes through
6
+ // `Turbo.config.forms.confirm` — never runs for a reactive trigger. That made
7
+ // the reactive path the ONE interaction a Hotwire app couldn't theme: every
8
+ // confirmable reactive action showed the unstyled native window.confirm chrome.
9
+ //
10
+ // This module is the seam. `dispatch` resolves the confirm message through
11
+ // `confirmResolver`, which defaults to a Promise-wrapped window.confirm (so the
12
+ // 0.4.5 behavior is byte-for-byte unchanged when nothing is configured: sync,
13
+ // no dependency, screen-reader friendly). An app reuses its themed dialog with
14
+ // one line at boot:
15
+ //
16
+ // import { setConfirmResolver } from "phlex/reactive/confirm"
17
+ // setConfirmResolver((message) => window.Turbo.config.forms.confirm(message))
18
+ //
19
+ // The resolver may be sync or async — the controller awaits it via
20
+ // Promise.resolve, so a bare boolean and a Promise<boolean> both work. It must
21
+ // resolve truthy to proceed; a falsy resolve (or a rejection) cancels the
22
+ // action — exactly like declining the native prompt.
23
+
24
+ // The default: wrap the synchronous native confirm in a Promise so the call
25
+ // site can always `await` it. Read window lazily (per call), not at module load
26
+ // — under SSR / test the global may not exist yet when this module is imported.
27
+ export let confirmResolver = (message) =>
28
+ Promise.resolve(typeof window !== "undefined" ? window.confirm(message) : true)
29
+
30
+ // Override the resolver. Pass a function `(message) => boolean | Promise<boolean>`.
31
+ // Truthy resolves the action through; falsy (or a rejected Promise) cancels it.
32
+ export function setConfirmResolver(fn) {
33
+ confirmResolver = fn
34
+ }
@@ -1,4 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ // Import the BARE specifier the engine already pins (phlex/reactive/confirm),
3
+ // NOT a relative "./confirm.js" (issue #57). Under importmap-rails + Propshaft
4
+ // the controller is served at its DIGESTED url; a relative sibling import is
5
+ // left untouched (Propshaft rewrites only RAILS_ASSET_URL(...), and the import
6
+ // map resolves ONLY bare specifiers), so "./confirm.js" resolves against the
7
+ // digested controller url → an undigested /assets/.../confirm.js that 404s, and
8
+ // the throwing import takes down every Stimulus controller on the page. The
9
+ // bare specifier resolves to the digested asset through the import map, and
10
+ // bundlers/bun resolve it the same way they already resolve
11
+ // "phlex/reactive/reactive_controller" (see tsconfig.json paths for the tests).
12
+ import { confirmResolver } from "phlex/reactive/confirm"
2
13
 
3
14
  // The ONE generic controller behind every reactive Phlex component. It
4
15
  // replaces the per-feature Stimulus controllers you'd otherwise hand-write
@@ -171,37 +182,53 @@ export default class extends Controller {
171
182
  const { action, params, debounce, confirm } = event.params
172
183
  if (!action) return
173
184
 
174
- // Confirmation gate (issue #52). A destructive reactive trigger can't use
175
- // Hotwire's data-turbo-confirm — this controller preempts the event — so a
176
- // `confirm:` message threads a data-reactive-confirm-param and we prompt
177
- // HERE, BEFORE any preventDefault/enqueue/debounce. On decline we still
178
- // preventDefault (so a `submit`/click can't natively navigate on cancel)
179
- // and bail — nothing is enqueued, no timer is scheduled. window.confirm is
180
- // synchronous + screen-reader friendly and keeps the no-dependency default;
181
- // a richer/async dialog can be layered on additively later.
182
- if (confirm && !window.confirm(confirm)) {
183
- event.preventDefault()
184
- return
185
- }
186
-
187
185
  // Stop native behavior (button submit / FORM NAVIGATION) HERE, synchronously
188
- // within the event dispatch. preventDefault() only works while the event is
189
- // still being handled deferring it into the request-queue microtask (below)
190
- // is too late: a `submit` trigger would natively POST the form and navigate
191
- // before the reactive round trip runs (issue #11). For a `click` trigger
192
- // there's no default to miss, so this was previously invisible. This holds
186
+ // within the event dispatch BEFORE the (possibly async) confirm gate below.
187
+ // preventDefault() only works while the event is still being handled; once we
188
+ // await the confirm resolver it's too late, and a `submit` trigger would
189
+ // natively POST the form and navigate before the reactive round trip runs
190
+ // (issue #11). For a `click` trigger there's no default to miss. This holds
193
191
  // for debounced triggers too — the round trip is deferred, but the native
194
- // default must still be prevented now.
192
+ // default must still be prevented now. (Moved ahead of the confirm branch in
193
+ // issue #55: an async resolver means we can't preventDefault after awaiting.)
195
194
  event.preventDefault()
196
195
 
196
+ // Capture the trigger element now; #proceed runs in a later microtask (after
197
+ // the confirm resolver settles), by which point event.target may be reset.
198
+ const target = event.target
199
+
200
+ // No confirm message → proceed straight away (unchanged fast path).
201
+ if (!confirm) return this.#proceed(target, action, params, debounce)
202
+
203
+ // Confirmation gate (issue #52, made overridable + async in #55). A reactive
204
+ // trigger can't use Hotwire's data-turbo-confirm — this controller preempts
205
+ // the event — so a `confirm:` message routes through confirmResolver (default
206
+ // window.confirm; an app can override it to reuse Turbo.config.forms.confirm).
207
+ // The resolver may be sync or async; call it INSIDE the chain (via the leading
208
+ // .then) so even a SYNCHRONOUS override throw rejects this promise instead of
209
+ // escaping dispatch — a throwing dialog is treated as a cancel, like the user
210
+ // dismissing it. The .catch is scoped to the resolver step (→ false = cancel),
211
+ // so a dismissed/erroring dialog never surfaces as an unhandled rejection AND a
212
+ // genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
213
+ // truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
214
+ Promise.resolve()
215
+ .then(() => confirmResolver(confirm))
216
+ .catch(() => false)
217
+ .then((ok) => {
218
+ if (ok) this.#proceed(target, action, params, debounce)
219
+ })
220
+ }
221
+
222
+ // Enqueue the action — debounced if a debounce window is set, else immediately.
223
+ // Split out of dispatch so both the no-confirm fast path and the post-confirm
224
+ // microtask share one place (issue #55). `target` is captured up front because
225
+ // this can run in a later microtask, after event.target has been reset.
226
+ #proceed(target, action, params, debounce) {
197
227
  // Debounced trigger (e.g. on(:update, event: "input", debounce: 300)):
198
228
  // coalesce rapid events into ONE round trip after a quiet period, instead of
199
229
  // one POST per keystroke (issue #17). A blur flushes a pending dispatch.
200
230
  const ms = Number(debounce) || 0
201
- if (ms > 0) return this.#debounceDispatch(event.target, ms, action, params)
202
-
203
- // Capture action/params now; the queued work runs in a later microtask, by
204
- // which point the event object may have been reset by the browser.
231
+ if (ms > 0) return this.#debounceDispatch(target, ms, action, params)
205
232
  return this.#enqueue(action, params)
206
233
  }
207
234
 
@@ -24,7 +24,10 @@ module Phlex
24
24
  initializer "phlex_reactive.assets" do
25
25
  if it.config.respond_to?(:assets)
26
26
  it.config.assets.paths << root.join("app/javascript").to_s
27
- it.config.assets.precompile += %w[phlex/reactive/reactive_controller.js]
27
+ it.config.assets.precompile += %w[
28
+ phlex/reactive/reactive_controller.js
29
+ phlex/reactive/confirm.js
30
+ ]
28
31
  end
29
32
  end
30
33
 
@@ -38,6 +41,19 @@ module Phlex
38
41
  to: "phlex/reactive/reactive_controller.js",
39
42
  preload: true
40
43
  )
44
+ # The overridable confirm resolver (issue #55). reactive_controller.js
45
+ # imports it by this BARE specifier — `import { confirmResolver } from
46
+ # "phlex/reactive/confirm"` — NOT a relative "./confirm.js" (issue #57:
47
+ # a relative sibling import inside the digested controller resolves to
48
+ # an undigested /assets/.../confirm.js that 404s under Propshaft). This
49
+ # pin maps the bare specifier to the digested asset, so the controller's
50
+ # own import AND an app's `import { setConfirmResolver } from
51
+ # "phlex/reactive/confirm"` both resolve through the import map.
52
+ it.importmap.pin(
53
+ "phlex/reactive/confirm",
54
+ to: "phlex/reactive/confirm.js",
55
+ preload: true
56
+ )
41
57
  end
42
58
  end
43
59
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.4.5"
5
+ VERSION = "0.4.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.4.5
4
+ version: 0.4.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -117,6 +117,7 @@ files:
117
117
  - LICENSE.txt
118
118
  - README.md
119
119
  - app/controllers/phlex/reactive/actions_controller.rb
120
+ - app/javascript/phlex/reactive/confirm.js
120
121
  - app/javascript/phlex/reactive/reactive_controller.js
121
122
  - lib/generators/phlex/reactive/component/USAGE
122
123
  - lib/generators/phlex/reactive/component/component_generator.rb