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.
This commit is contained in:
B.D. Murphy
2026-10-10 20:20:43 -07:00
parent 305f6a3912
commit 9328159ec1
3 changed files with 119 additions and 17 deletions

View File

@@ -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"

View File

@@ -171,13 +171,23 @@
<form hx-put="/api/speakers/{{ speaker_idx }}"
hx-target="#speaker-row-{{ speaker_idx }}"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful){ queueVoiceRegenOnSave({{ speaker_idx }}); }">
{# 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. #}
<div style="display:grid; grid-template-columns:1fr 1fr 1fr 1fr; gap:0.5rem; margin-bottom:0.5rem">
<input type="text" name="primary_name" value="{{ speaker.primary_name }}" required
style="padding:0.4rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.85rem">

View File

@@ -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;