Skip to content

fix(client): interpolate %{field} in nested-remove confirm on client-added rows (#222) - #223

Merged
mhenrixon merged 1 commit into
mainfrom
issue-222-nested-remove-confirm-clone-interpolation
Jul 10, 2026
Merged

fix(client): interpolate %{field} in nested-remove confirm on client-added rows (#222)#223
mhenrixon merged 1 commit into
mainfrom
issue-222-nested-remove-confirm-clone-interpolation

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #222.

The bug

reactive_nested_remove(confirm:) (#218) renders the confirm message per row, which is correct for rows the server rendered. But a row added client-side via reactive_nested_add is a cloneNode(true) of the <template>, and the clone path (#renumberNestedRow / #seedNestedRow) only rewrites name/id/for — it never touches data-reactive-confirm[-when]-param. So every client-added row carried the template's confirm string (a value-less "Delete '%{name}'?" or a blank-interpolated string), not one that reflected the row the user actually built — on exactly the rows the primitive exists for.

The fix (issue proposal 2 + a superset of proposal 3)

Click-time %{field} interpolation. On remove, nestedRemove resolves %{field} placeholders in the confirm message from that row's live field values — read from the row's own inputs via #nestedRowObject (keyed by the same trailing-bracket inference as: :json uses, so the two can't drift). Because it resolves at click time (not clone time), a later edit to the row is reflected too. This fixes the bug for every app with zero app JS, on the default window.confirm.

# template row is value-less → renders the literal "%{name}"/"%{amount}"
button(**reactive_nested_remove(confirm: "Delete '%{name}' (%{amount})?")) { "×" }
# a row the user filled with Widget/42 now prompts:  Delete 'Widget' (42)?
  • An unresolved %{key} (no matching row field) is left as its literal text — visible and debuggable, never a silent blank.
  • The conditional { when:, message: } Hash's message: is interpolated too.
  • Server-rendered rows already interpolate server-side (their finished strings carry no %{}), so this is a no-op for them.

Optional resolver context (proposal 3, as a superset). confirmResolver now receives an optional 2nd argument: { el } (the trigger) on every confirm path — dispatch, runOps, nestedRemove — plus { row, fields } on nestedRemove. A themed-dialog override can build row-specific messages programmatically. The arg is purely additive: a one-parameter resolver (and window.confirm) is unchanged.

setConfirmResolver((message, ctx) => {
  // message is already interpolated; ctx = { el, row, fields }
  return myThemedDialog(message, ctx)
})

Test coverage

Layer What it proves
JS unit (reactive_nested.test.js, 6 new) interpolation from live values; literal left on missing key; a client-ADDED row reflects its own typed value; a LATER edit is reflected (click-time, not clone-time); the confirm-when Hash message interpolates; the resolver gets { el, row, fields }
System (draft_order_confirm_interpolate_spec.rb, 2 new) the added row's own typed quantity is interpolated into its confirm (Puma and Falcon); a later edit is reflected
Regression existing #218 confirm-remove + #52/#55/#178/#179 confirm specs unchanged

All gates green: rubocop (288 files), rspec spec/phlex spec/requests (1357), the confirm + draft system suite under Puma and Falcon, bun test spec/javascript (533). Client rebuilt (rake build:js) and the vendored copy re-synced (byte-identical, sync spec green). An adversarial multi-agent review found no blocking bugs — it empirically verified (Node) that $-replacement patterns in a field value are inserted literally (function callback, not string replacement), that the substitution is non-recursive (a field value of %{x} does not then resolve field x — no injection, no infinite loop), and that the field-map keys match the placeholder in both wire modes.

Docs & changelog

  • README setConfirmResolver section documents the ctx 2nd arg + %{field} on client-added rows.
  • docs/…/draft_rows_new_parent.rb "Confirm before removing a row" now distinguishes server-rendered (interpolate at render) from client-added (%{field} placeholder).
  • CHANGELOG.md under Unreleased → Fixed.

Deviations & judgment calls

  • Threaded ctx to ALL THREE confirmResolver call sites, not just nestedRemove. dispatch/runOps now pass { el } so an app's resolver override has a uniform (message, ctx) signature everywhere (one function covers every confirm path); nestedRemove passes the fuller { el, row, fields }. Interpolation itself is nested-only (only nestedRemove has a row). Judgment: a uniform signature beats a nested-only 2nd arg that would make ctx sometimes-present.
  • runOps captured no trigger element before this change — added const trigger = event.currentTarget ?? event.target (the idiom dispatch already uses) purely to populate ctx.el. Inert otherwise.
  • Test-hygiene discovery: confirmResolver is module-global mutable state and bun runs test files in one worker. The new ctx test installs a (message, ctx) => false resolver, which leaked into reactive_confirm.test.js (relies on the default window.confirm, no reset) and zeroed its fetches. Fixed with an afterAll in reactive_nested.test.js that restores the shipped default — mirroring reactive_confirm_resolver.test.js. Not a product bug; a gap the new test surfaced.
  • Accepted trade-off (not a bug), surfaced by the review: a server-rendered reactive_nested_remove confirm containing a literal %{word} is now interpolated if that row has a field keyed word (e.g. a message "…20%{discount} rule…" on a row with a discount field → "…2010 rule…"). Blast radius: only reactive_nested_remove (never on/on_client), only a persisted row whose message literally contains %{fieldkey}. There is no %% escape (adding one is more surface than this contrived case warrants). %{} was never a documented-safe literal in a confirm; the docs now explicitly flag this collision as a footgun and the CHANGELOG names %{field} as the syntax.
  • Missing-key behavior: chosen per the linked question — leave the literal %{key} visible rather than empty-substitute.
  • Placeholder syntax: %{field} (Ruby-style, matches the issue). Required one scoped rubocop:disable Style/FormatStringToken on the dummy component + docs page (these are client-interpolation templates, not Ruby format strings), each with a rationale comment.

Summary by CodeRabbit

  • Bug Fixes

    • Confirmation messages for dynamically added rows now interpolate placeholders using the row’s current field values at click time.
    • Unmatched placeholders remain visible as literal text.
    • Conditional confirmation messages now support the same interpolation behavior.
  • Enhancements

    • Confirmation resolvers can access trigger, row, and field-value context through an optional second argument.
    • Existing one-argument confirmation resolvers remain compatible.
  • Documentation

    • Added guidance and examples for live row-value interpolation and resolver context.

…added rows (#222)

## Summary
A row added client-side via reactive_nested_add is a cloneNode of the
<template>, and the clone path (#renumberNestedRow / #seedNestedRow) never
rewrote data-reactive-confirm[-when]-param — so a per-row confirm like
"Delete '%{name}'?" froze to the template's value-less string on every added
row (the exact rows the primitive exists for).

nestedRemove now resolves %{field} placeholders in the confirm message from
THAT row's live field values (via #nestedRowObject, keyed by the same
trailing-bracket inference as: :json uses) at click time — so a later edit is
reflected too. An unresolved %{key} is left as its literal text (debuggable,
never a silent blank); the confirm-when Hash message interpolates as well.
Server-rendered rows already interpolate server-side, so their finished strings
(no %{}) are a no-op.

Superset of the issue's proposal 3: confirmResolver now receives an optional
2nd arg — { el } on every confirm path (dispatch/runOps/nestedRemove), plus
{ row, fields } on nestedRemove — so a themed-dialog override can build
row-specific messages. Additive: a one-parameter resolver (and window.confirm)
is unchanged.

## Test Coverage
- spec/javascript/reactive_nested.test.js: 6 new — interpolation from live
  values, literal on missing key, client-added row reflects its own value,
  later-edit (click-time not clone-time), confirm-when message interpolation,
  resolver ctx { el, row, fields }
- spec/system/draft_order_confirm_interpolate_spec.rb: 2 new — the ADDED row's
  own typed quantity interpolated into its confirm (Puma + Falcon), later edit
- Existing #218 confirm-remove + #52/#55/#178/#179 confirm specs unchanged

## Verification
- [x] bundle exec rubocop (288 files, clean)
- [x] bundle exec rspec spec/phlex spec/requests (1357 examples)
- [x] confirm + draft system specs green under Puma AND Falcon
- [x] bun test spec/javascript (533)
- [x] client rebuilt (rake build:js) + vendored copy re-synced (sync spec green)
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

reactive_nested_remove(confirm:) now interpolates confirmation placeholders from live row fields, preserves unresolved tokens, and passes trigger/row context to confirmResolver. Documentation, dummy-app coverage, JavaScript tests, system tests, and the generated bundle are updated.

Changes

Nested-remove confirmation flow

Layer / File(s) Summary
Confirmation runtime and resolver context
app/javascript/phlex/reactive/reactive_controller.js, app/javascript/phlex/reactive/confirm.js
Confirmation calls receive trigger context; nested-remove messages use live row fields and pass { el, row, fields }.
Confirmation API documentation
README.md, docs/app/views/docs/pages/draft_rows_new_parent.rb, CHANGELOG.md
Documentation describes interpolation, resolver context, conditional messages, and unresolved placeholders.
Draft-order interpolation demonstration
spec/dummy/config/routes.rb, spec/dummy/app/controllers/demos_controller.rb, spec/dummy/app/components/draft_order_confirm_interpolate_component.rb
A dummy draft-order form demonstrates quantity interpolation in client-added row confirmations.
Runtime and system validation
spec/javascript/reactive_nested.test.js, spec/system/draft_order_confirm_interpolate_spec.rb, spec/dummy/public/vendor/reactive_controller.js
Tests cover live values, row-specific messages, resolver context, and shared resolver cleanup; the vendor bundle is regenerated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Suggested labels: enhancement, documentation

Poem

I’m a rabbit with rows in a neat little line,
Typed values now make each prompt shine.
“Remove item seven?” I cheer with delight,
Live fields are gathered right at click time.
Unmatched tokens stay, safely in sight—
Hop, hop, confirmed: the flow feels right!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: client-side interpolation of %{field} placeholders in nested-remove confirmations for client-added rows.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@mhenrixon mhenrixon self-assigned this Jul 10, 2026
@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation enhancement New feature or request labels Jul 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
docs/app/views/docs/pages/draft_rows_new_parent.rb (1)

306-314: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a DocsUI::Callout(:note) for the unresolved-placeholder caveat.

This caveat text (unresolved %{key} behavior, no-escape gotcha) is the same category of content as the Callout(:note) blocks used elsewhere in this file (json_mode, fill_then_add). As per coding guidelines: "Use the dedicated reference helpers (DocsUI::PropTable, DocsUI::FieldTable, DocsUI::RequestExample, DocsUI::Callout(:note | :tip | :warning)) before writing equivalent prose manually."

♻️ Proposed refactor
-              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.
+              The placeholder works in the conditional Hash's `message:` too.
+              Interpolation applies to `reactive_nested_remove` confirms only —
+              `on`/`on_client` confirms never substitute.
             MD
+          end
+
+          DocsUI::Callout(:note) do
+            md <<~'MD'
+              A `%{key}` with no matching row field is left as its literal text
+              (visible and debuggable, never a silent blank). 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.
+            MD
           end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/app/views/docs/pages/draft_rows_new_parent.rb` around lines 306 - 314,
Replace the manual unresolved-placeholder caveat prose in the
reactive_nested_remove documentation with a DocsUI::Callout(:note), following
the existing json_mode and fill_then_add callout patterns in the same file;
preserve all details about literal unresolved %{key} text, message
interpolation, non-interpolated on/on_client confirms, and the lack of an escape
mechanism.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@spec/javascript/reactive_nested.test.js`:
- Around line 47-51: Update the cleanup around stubConfirm to capture the
original window.confirm before stubbing, then in afterAll restore window.confirm
to that original implementation and reset confirmModule’s resolver to delegate
to the restored original (while retaining the non-browser fallback). Ensure
later tests do not inherit the stubbed confirmation behavior.

---

Nitpick comments:
In `@docs/app/views/docs/pages/draft_rows_new_parent.rb`:
- Around line 306-314: Replace the manual unresolved-placeholder caveat prose in
the reactive_nested_remove documentation with a DocsUI::Callout(:note),
following the existing json_mode and fill_then_add callout patterns in the same
file; preserve all details about literal unresolved %{key} text, message
interpolation, non-interpolated on/on_client confirms, and the lack of an escape
mechanism.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 83f0fe56-acc6-49b6-8ea6-f9872bdd99da

📥 Commits

Reviewing files that changed from the base of the PR and between 2508d1f and 252ada3.

⛔ Files ignored due to path filters (3)
  • app/javascript/phlex/reactive/confirm.min.js.map is excluded by !**/*.map, !**/*.min.js.map
  • app/javascript/phlex/reactive/reactive_controller.min.js is excluded by !**/*.min.js
  • app/javascript/phlex/reactive/reactive_controller.min.js.map is excluded by !**/*.map, !**/*.min.js.map
📒 Files selected for processing (11)
  • CHANGELOG.md
  • README.md
  • app/javascript/phlex/reactive/confirm.js
  • app/javascript/phlex/reactive/reactive_controller.js
  • docs/app/views/docs/pages/draft_rows_new_parent.rb
  • spec/dummy/app/components/draft_order_confirm_interpolate_component.rb
  • spec/dummy/app/controllers/demos_controller.rb
  • spec/dummy/config/routes.rb
  • spec/dummy/public/vendor/reactive_controller.js
  • spec/javascript/reactive_nested.test.js
  • spec/system/draft_order_confirm_interpolate_spec.rb

Comment thread spec/javascript/reactive_nested.test.js
@mhenrixon

Copy link
Copy Markdown
Collaborator Author

Thanks — I looked at this closely and it's a push-back: the described leak can't occur, and the suggested change would actually make it less correct.

The afterAll resolver reads window.confirm lazily. It's a closure — (message) => Promise.resolve(globalThis.window.confirm(message)) — that resolves globalThis.window.confirm at call time, not at afterAll time. So it doesn't "wrap the last false stub"; it delegates to whatever window.confirm the next test file installs. This is byte-identical to the established pattern in reactive_confirm_resolver.test.js:33-36 (the file this comment cites as the precedent), and it's exactly what a later file needs.

window.confirm can't leak across files either, because every confirm test file reassigns globalThis.window to a fresh object in its own buildController before stubbing — reactive_confirm.test.js:63, reactive_confirm_conditional.test.js:76, reactive_confirm_resolver.test.js:78, reactive_run_ops_confirm.test.js:66, and this file at :196. The next file overwrites globalThis.window wholesale, so a stale confirm from a prior file is discarded before it could be read.

Capturing/restoring the "original" window.confirm would be wrong here. At module-load time bun provides no window at all (buildController creates it lazily), so there is no pristine original to capture — capturing at afterAll time would freeze the resolver to this file's last stub, defeating the lazy delegation the next file relies on.

Verified empirically — both cross-file orderings and the full suite are green:

bun test reactive_nested.test.js reactive_confirm.test.js   → 36 pass
bun test reactive_confirm.test.js reactive_nested.test.js   → 36 pass
bun test spec/javascript                                     → 533 pass

Leaving the afterAll as-is (matching the resolver-spec precedent). No code change.

@mhenrixon
mhenrixon merged commit 8392fe5 into main Jul 10, 2026
11 checks passed
@mhenrixon
mhenrixon deleted the issue-222-nested-remove-confirm-clone-interpolation branch July 10, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reactive_nested_remove(confirm:) frozen to the template string on client-added rows

1 participant