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:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user