From 9328159ec14e24b3ddfb2763137fc3cf63d24aff Mon Sep 17 00:00:00 2001 From: "B.D. Murphy" <161863146+bdmurphy73@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:20:43 -0700 Subject: [PATCH] Fix re-voice never firing, by driving it from the event that carries the form Reported: Save & Re-voice still gave no response after the previous fix. That fix bound hx-on::after-request to the form. It never fires. htmx reports a different detail.elt for each phase of this request - measured in a real browser: beforeRequest FORM beforeSwap TR afterRequest TBODY Row swaps get re-parented into a table context, so afterRequest is dispatched on the enclosing TBODY. A listener on the form never sees it, because the event does not bubble downwards. An earlier probe suggested the attribute worked, but that probe used a form outside a table, where elt stays the FORM - it did not reproduce the real page. So the profile was being saved (the PUT fired and swapped) while the re-voice never ran and nothing on screen confirmed anything. That is exactly "no response". - Capture the row index at beforeRequest, where detail.elt is still the form, and match it to the completing request by path at afterRequest so a different response can never trigger a re-voice for the wrong speaker. - Saving now shows a confirmation toast. The button previously looked dead even when the edit did save. - Tests now guard the mechanism, including that hx-on::after-request is not reintroduced on this form. Verified by clicking the button in a real browser on Deal Hunter's Dealer: POST /api/voices/queue with {"speaker_name":"Dealer","row_index":14} and the confirmation shown. Suite: 690 passed, 8 pre-existing. --- tests/test_speaker_edit_submit.py | 41 ++++++++++--- umbraaudio/web/templates/_speaker_row.html | 24 +++++--- umbraaudio/web/templates/speakers.html | 71 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 17 deletions(-) diff --git a/tests/test_speaker_edit_submit.py b/tests/test_speaker_edit_submit.py index 70dfb7e..0e6e977 100644 --- a/tests/test_speaker_edit_submit.py +++ b/tests/test_speaker_edit_submit.py @@ -77,19 +77,40 @@ def test_every_onsubmit_return_handler_returns_a_boolean() -> None: def test_voice_regen_on_save_is_not_empty() -> None: - """The specific function that was gutted.""" + """The specific handler that was gutted in the voice-queue commit.""" bodies = _function_bodies((TEMPLATES / "speakers.html").read_text()) - assert "queueVoiceRegenOnSave" in bodies, "handler is missing entirely" - body = bodies["queueVoiceRegenOnSave"].strip() - assert body, "queueVoiceRegenOnSave has an empty body" + assert "onSpeakerSaved" in bodies, "handler is missing entirely" + body = bodies["onSpeakerSaved"].strip() + assert body, "onSpeakerSaved has an empty body" assert "enqueueVoice" in body, "it must actually queue a regeneration" + assert "notify" in body, "a save must tell the reviewer it happened" -def test_edit_form_queues_regen_after_a_successful_save() -> None: - """Re-voice must run after the new profile is persisted, not before.""" +def test_edit_form_carries_the_regen_marker() -> None: row = _strip_comments((TEMPLATES / "_speaker_row.html").read_text()) - assert "queueVoiceRegenOnSave" in row - assert "hx-on::after-request" in row, ( - "regeneration should be hooked to after-request so the saved profile " - "is on disk before the voice is rebuilt from it" + assert "data-regen-on-save" in row, ( + "the form must be identifiable so a save can trigger a re-voice" ) + assert "hx-on::after-request" not in row, ( + "htmx dispatches afterRequest on the enclosing TBODY for a row swap, " + "so a listener bound to the form never fires - verified in a real browser" + ) + + +def test_regen_trigger_uses_the_event_that_carries_the_form() -> None: + """detail.elt is the FORM at beforeRequest but the TBODY at afterRequest.""" + js = _strip_comments((TEMPLATES / "speakers.html").read_text()) + assert "htmx:beforeRequest" in js, ( + "the index must be captured at beforeRequest, where detail.elt is still " + "the form; reading it at afterRequest yields a TBODY and finds nothing" + ) + assert "htmx:afterRequest" in js + assert "onSpeakerSaved" in js + + +def test_saving_tells_the_reviewer() -> None: + """A save with no visible response reads as a broken button.""" + bodies = _function_bodies(_strip_comments((TEMPLATES / "speakers.html").read_text())) + body = bodies["onSpeakerSaved"] + assert "enqueueVoice" in body, "it must actually queue a regeneration" + assert "notify" in body, "it must confirm to the reviewer" diff --git a/umbraaudio/web/templates/_speaker_row.html b/umbraaudio/web/templates/_speaker_row.html index 8001ed3..e4168b6 100644 --- a/umbraaudio/web/templates/_speaker_row.html +++ b/umbraaudio/web/templates/_speaker_row.html @@ -171,13 +171,23 @@
- {# Re-voice is hooked to after-request, NOT onsubmit. An - onsubmit="return fn(...)" whose fn falls through returns - undefined, which is falsy, so the browser cancels the whole - submit and silently discards every edit. Queueing here also - guarantees the saved profile is on disk before the voice is - regenerated from it. #} + data-regen-on-save="{{ speaker_idx }}"> + {# Re-voice fires after a successful save, never before: the profile must be + on disk before the voice is rebuilt from it, and a failed + save must queue nothing. + + Three traps, all hit here already: + - Not onsubmit="return fn(...)". Such a handler that falls + off the end returns undefined, which is falsy, so the + browser cancels the submit and every edit is discarded. + - Not hx-on::after-request. htmx dispatches afterRequest on + the TBODY for a row swap, not on this form, so a listener + bound to the form never fires. + - Not detail.elt in a body-level afterRequest handler either: + elt is the FORM at beforeRequest but the TBODY at + afterRequest. The index is captured at beforeRequest, where + the form is still the event target, and matched to the + completing request by path. See speakers.html. #}
diff --git a/umbraaudio/web/templates/speakers.html b/umbraaudio/web/templates/speakers.html index d05778c..bb22404 100644 --- a/umbraaudio/web/templates/speakers.html +++ b/umbraaudio/web/templates/speakers.html @@ -37,6 +37,77 @@ var _regenIdx = null; // The name is re-read from the swapped row because the reviewer may have just // renamed the speaker, and regenerating under the old name would voice a // speaker that no longer exists. +// "Save & Re-voice" plumbing. +// +// htmx reports a DIFFERENT detail.elt for each phase of a request on this form: +// the FORM at beforeRequest, the TR at beforeSwap, and the TBODY at afterRequest +// (row swaps get re-parented into a table context). So neither a form-level +// hx-on::after-request nor a body-level lookup on detail.elt works - the first +// never fires, the second finds a TBODY with no attributes on it. +// +// Capture the row index at beforeRequest, where the form is still the event +// target, and match it to the completing request by path. +var _pendingRegen = null; + +document.body.addEventListener('htmx:beforeRequest', function (event) { + var elt = event.detail && event.detail.elt; + if (!elt || typeof elt.getAttribute !== 'function') { + return; + } + var raw = elt.getAttribute('data-regen-on-save'); + if (raw === null || raw === undefined) { + return; + } + _pendingRegen = { + idx: parseInt(raw, 10), + path: (event.detail.path || '').split('?')[0] + }; +}); + +document.body.addEventListener('htmx:afterRequest', function (event) { + var pending = _pendingRegen; + _pendingRegen = null; + if (!pending || !event.detail || !event.detail.successful) { + return; + } + var done = ''; + if (event.detail.path) { + done = event.detail.path.split('?')[0]; + } else if (event.detail.pathInfo && event.detail.pathInfo.finalRequestPath) { + done = event.detail.pathInfo.finalRequestPath.split('?')[0]; + } + // Never act on a request other than the one we stashed against. + if (done && pending.path && done !== pending.path) { + return; + } + onSpeakerSaved(pending.idx); +}); + +function onSpeakerSaved(idx) { + var row = document.getElementById('speaker-row-' + idx); + var label = row ? row.querySelector('strong') : null; + var name = label ? label.textContent.trim() : ''; + if (!name) { + notify('Saved, but could not read the speaker name back to re-voice it.', true); + return; + } + enqueueVoice(name, idx); + notify('Saved ' + name + ' \u2014 re-voice queued. You can keep reviewing.'); +} + +// Without this the button looks dead: the row swaps, but nothing on screen +// says the edit was taken or that work was started. +function notify(message, isError) { + var el = document.createElement('div'); + el.textContent = message; + el.style.cssText = 'position:fixed; bottom:1.2rem; right:1.2rem; z-index:1100; ' + + 'background:' + (isError ? '#7f1d1d' : '#065f46') + '; color:#f0fdf4; ' + + 'padding:0.6rem 0.9rem; border-radius:6px; font-size:0.84rem; max-width:24rem; ' + + 'box-shadow:0 4px 12px rgba(0,0,0,0.4)'; + document.body.appendChild(el); + setTimeout(function () { el.remove(); }, 5000); +} + function queueVoiceRegenOnSave(idx) { var row = document.getElementById('speaker-row-' + idx); var label = row ? row.querySelector('strong') : null;