Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixed

- **`reactive_nested_remove(confirm:)` now interpolates `%{field}` on client-added
rows (#222).** A row added in the browser via `reactive_nested_add` is a
`cloneNode` of the `<template>`, and the clone path (`#renumberNestedRow` /
`#seedNestedRow`) never rewrote the confirm attribute — so a per-row confirm
froze to the template's value-less string on every added row (the exact rows
the primitive exists for). The client now resolves `%{field}` placeholders in
the confirm message from **that row's live field values** at click time (keyed
by the same trailing-bracket inference `as: :json` uses), so
`confirm: "Delete '%{name}'?"` on the template shows `Delete 'Widget'?` on the
added row — reflecting a later edit too (resolved on remove, not on clone). An
unresolved `%{key}` is left as its literal text (debuggable, never a silent
blank); the placeholder works in the conditional Hash's `message:` as well.
Server-rendered rows already interpolate server-side, so their finished strings
are unaffected. **Also (superset of the issue's proposal 3):** `confirmResolver`
now receives an optional second argument — a context object, always `{ el }`
(the trigger), plus `{ row, fields }` on a `reactive_nested_remove` — so a
themed-dialog override can build row-specific messages programmatically. The
arg is additive: a one-parameter resolver (and `window.confirm`) is unchanged.

- **A draft (unsaved-parent) token can now round-trip real server actions (#208).**
`Component::Identity` already signed a gid-less `{c, state}` token for an
unpersisted (or nil) record, but `from_identity` still `fetch`ed the absent
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1512,9 +1512,27 @@ the user dismissed the dialog) cancels the action, exactly like declining the
native prompt. The native default is always prevented up front, so a `submit`
trigger never navigates while the dialog is open.

Unset, behavior is identical to the native `window.confirm` — the `confirm:`
markup and `on(...)` API are unchanged; only the client's resolution strategy
gains a seam.
The resolver also gets an **optional second argument** — a context object — so a
power-user override can build the string itself instead of relying on the message
alone. It always carries `{ el }` (the trigger element the confirm fired from);
on a `reactive_nested_remove` it additionally carries `{ row, fields }` (the row
element and its `{ key => value }` field map), so a themed dialog can render
row-specific detail programmatically:

```js
setConfirmResolver((message, ctx) => {
// message is already interpolated (client-added rows resolve %{field}, see below)
return myThemedDialog(message, { trigger: ctx.el, fields: ctx.fields })
})
```

The second argument is purely additive — a one-parameter resolver keeps working
untouched. Unset, behavior is identical to the native `window.confirm`; the
`confirm:` markup and `on(...)` API are unchanged. For per-row confirm messages
on **client-added** draft rows, the message the resolver receives is already
interpolated from the row's live field values (`confirm: "Delete '%{name}'?"` →
`Delete 'Widget'?`) — see [Draft rows for a new
parent](#draft-rows-for-a-new-parent-reactive_nested_).

### `reply` — controlling the action's reply

Expand Down
8 changes: 8 additions & 0 deletions app/javascript/phlex/reactive/confirm.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@
// Promise.resolve, so a bare boolean and a Promise<boolean> both work. It must
// resolve truthy to proceed; a falsy resolve (or a rejection) cancels the
// action — exactly like declining the native prompt.
//
// The resolver receives an OPTIONAL 2nd arg (issue #222): a context object,
// always `{ el }` (the trigger element), plus `{ row, fields }` on a
// reactive_nested_remove (the row element + its { key: value } field map). It's
// purely additive — a one-arg resolver (and window.confirm, which ignores extra
// args) keeps working unchanged. On a client-added draft row the `message` the
// resolver receives is already interpolated from the row's live field values
// (`confirm: "Delete '%{name}'?"` → "Delete 'Widget'?").

// The default: wrap the synchronous native confirm in a Promise so the call
// site can always `await` it. Read window lazily (per call), not at module load
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/phlex/reactive/confirm.min.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 39 additions & 8 deletions app/javascript/phlex/reactive/reactive_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1742,8 +1742,10 @@ export default class extends Controller {
// so a dismissed/erroring dialog never surfaces as an unhandled rejection AND a
// genuine bug inside #proceed is NOT silently swallowed. Enqueue ONLY on a
// truthy resolution — nothing is enqueued, no timer scheduled, otherwise.
// The resolver's optional 2nd arg (issue #222) carries the trigger element,
// so an override has the same ctx shape here as on nestedRemove ({ el, … }).
Promise.resolve()
.then(() => confirmResolver(message))
.then(() => confirmResolver(message, { el: target }))
.catch(() => false)
.then((ok) => {
if (ok) this.#proceed(target, action, params, debounce, throttle, optimistic, busy)
Expand All @@ -1758,6 +1760,9 @@ export default class extends Controller {
// owns state that must survive re-renders).
runOps(event) {
const { ops, confirm, confirmWhen, outside, window: windowBound } = event.params
// The trigger element on_client was spread onto (issue #222 ctx: { el }),
// captured now — currentTarget resets before the confirm resolver's microtask.
const trigger = event.currentTarget ?? event.target

// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
// outside: trigger is a COMPLETE no-op for events inside this root, before
Expand Down Expand Up @@ -1789,7 +1794,7 @@ export default class extends Controller {
// here (the user gesture), NOT in #applyOps: that applier is shared with the
// server-pushed reactive:js stream action, which must NEVER prompt.
Promise.resolve()
.then(() => confirmResolver(message))
.then(() => confirmResolver(message, { el: trigger }))
.catch(() => false)
.then((ok) => {
if (ok) this.#applyOps(this.#parseOps(ops))
Expand Down Expand Up @@ -2254,21 +2259,47 @@ export default class extends Controller {
// else null. No confirm attr → null → the immediate-remove fast path.
const confirm = trigger?.getAttribute?.("data-reactive-confirm-param")
const confirmWhen = trigger?.getAttribute?.("data-reactive-confirm-when-param")
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
if (!message) return this.#removeNestedRow(row)
const rawMessage = this.#effectiveConfirmMessage(confirm, confirmWhen)
if (!rawMessage) return this.#removeNestedRow(row)

// Per-row confirm interpolation (issue #222). A row added client-side is a
// cloneNode of the <template>, and the clone carries the TEMPLATE's confirm
// string verbatim — the renumber/seed steps never rewrite the confirm attr.
// So resolve %{field} placeholders here, from THIS row's live field values
// (read now, not at clone time, so a later edit is reflected). An unresolved
// key is left as its literal %{key} (debuggable, never throws). Server-
// rendered rows already interpolate server-side, so their finished strings
// carry no %{}; this is a no-op for them.
const fields = this.#nestedRowObject(row)
const message = this.#interpolateConfirm(rawMessage, fields)

// Gate through the overridable confirmResolver (issues #52/#55/#178) — a
// themed dialog set with setConfirmResolver covers this trigger too. Call the
// resolver INSIDE the chain so even a SYNCHRONOUS override throw is a cancel
// (like a dismissed dialog), and remove ONLY on a truthy resolution.
// themed dialog set with setConfirmResolver covers this trigger too. Pass the
// row context (issue #222, superset of proposal 3) as an optional 2nd arg so
// a power-user override can build the string itself; the message is already
// interpolated for the default window.confirm path. Call the resolver INSIDE
// the chain so even a SYNCHRONOUS override throw is a cancel (like a dismissed
// dialog), and remove ONLY on a truthy resolution.
return Promise.resolve()
.then(() => confirmResolver(message))
.then(() => confirmResolver(message, { el: trigger, row, fields }))
.catch(() => false)
.then((ok) => {
if (ok) this.#removeNestedRow(row)
})
}

// Resolve %{field} placeholders in a confirm message from a row's field map
// (issue #222). Ruby-style %{name} tokens; an unresolved key is left verbatim
// (a visible, debuggable placeholder — never an empty hole or a throw). A
// message with no placeholders returns unchanged, so this is inert for every
// server-rendered (already-interpolated) confirm string.
#interpolateConfirm(message, fields) {
if (!message.includes("%{")) return message
return message.replace(/%\{(\w+)\}/g, (whole, key) =>
Object.prototype.hasOwnProperty.call(fields, key) ? fields[key] : whole,
)
}

// The remove itself, shared by the confirmed and no-confirm paths. Draft rows
// leave the DOM; a persisted row (a hidden [_destroy] input present) is marked
// "1" + hidden instead (set-value + dispatch contract, #183), so Rails destroys
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/phlex/reactive/reactive_controller.min.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions app/javascript/phlex/reactive/reactive_controller.min.js.map

Large diffs are not rendered by default.

42 changes: 38 additions & 4 deletions docs/app/views/docs/pages/draft_rows_new_parent.rb
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ def fill_then_add
end
end

# The %{field} tokens in the confirm examples below are the CLIENT's
# interpolation syntax (the reactive runtime parses them on remove), not
# Ruby format strings — Style/FormatStringToken's annotated-token
# preference doesn't apply to documentation of client-side markup.
# rubocop:disable Style/FormatStringToken
def confirm_on_remove
DocsUI::Section('Confirm before removing a row') do
md <<~'MD'
Expand All @@ -269,27 +274,56 @@ def confirm_on_remove
`_destroy`-marked + hidden) and the existing JSON/attributes
re-sync; on **cancel** nothing happens. It works in both wire modes.

**Per-row messages come for free** — the confirm is a per-render
string, so a row that wants row-specific detail just renders a
different one (no gem-side interpolation feature needed, exactly as
**Server-rendered rows** get per-row detail for free — the confirm
is a per-render string, so a persisted row just renders its own (as
with `on(:destroy, confirm:)`):

```ruby
button(**reactive_nested_remove(
confirm: "Really delete #{row.name} (#{row.amount})?")) { '×' }
```

**Client-added rows use a `%{field}` placeholder (#222).** A row you
add in the browser is a `cloneNode` of the `<template>`, so it can
only carry the template's confirm string — and the template row has
no values yet. Interpolate at the client instead: put a
`%{field_key}` placeholder in the message, and on remove the client
substitutes **that row's own live field values** (keyed by the same
trailing-bracket inference `as: :json` uses). The message reflects
the row the user actually built, and a later edit is reflected too
(it resolves at click time, not clone time):

```ruby
# The template row is value-less, so this renders "%{name}"/"%{amount}"
# literally; the client fills them from the ADDED row on remove.
td { input(name: nested_field_name(:line_items, :name)) }
td { input(name: nested_field_name(:line_items, :amount), type: 'number') }
button(**reactive_nested_remove(
confirm: "Delete '%{name}' (%{amount})?")) { '×' }
# a row the user filled with "Widget"/42 prompts: Delete 'Widget' (42)?
```

A `%{key}` with no matching row field is left as its literal text
(visible and debuggable, never a silent blank). The placeholder
works in the conditional Hash's `message:` too. Interpolation
applies to `reactive_nested_remove` confirms only — `on`/`on_client`
confirms never substitute — so if a remove confirm needs a
**literal** `%{word}` where `word` also happens to be a field on the
row, reword it (there is no escape); this only bites text that
deliberately contains a field name in braces.

It also composes with the conditional-confirm Hash form — prompt
only when a condition matches, for parity with `on`/`on_client`:

```ruby
# Only ask if the row still has a value; a blank draft row goes quietly.
button(**reactive_nested_remove(
confirm: { when: { amount: 1.. }, message: 'Remove this line item?' })) { '×' }
confirm: { when: { amount: 1.. }, message: 'Remove %{name}?' })) { '×' }
```
MD
end
end
# rubocop:enable Style/FormatStringToken

def draft_actions
DocsUI::Section('Bonus: real server actions on a draft parent') do
Expand Down
Loading
Loading