appview/js: fix webhook test result, 400 toasts, and Layers tab init

Three unrelated client-side defects found while baselining production.

The webhook Test button always reported success. renderAlert writes no
status code, so both outcomes are HTTP 200 and the result lives in the
markup, which partials/alert.html emits as "alert alert-error". testWebhook
looked for class="error", which that string does not contain, and resp.ok
is always true, so the failure branch was unreachable. A webhook pointed at
a dead URL was reported as delivered. Match alert-error instead.

Avatar upload rejections lost the server's reason. The htmx:responseError
handler maps status codes to fixed strings and had no 400 case, so
"File too large (max 3MB)" and "Invalid file type" both surfaced as
"Something went wrong". Surface the body when it is short plain text; a
rendered error page or a long trace is not toast material.

The repository page's Layers tab was never initialised. initLayersTables
runs from DOMContentLoaded, when the panel is still a spinner, and from
htmx:afterSettle, which htmx.process() does not emit. So empty-layer hiding
and no-history run collapsing never ran there, and the checkbox claimed
rows were hidden while all of them were on screen. Export it and call it
from the tab controller. It now also re-seeds checkboxes within the loaded
scope, which fixes the digest page contradicting itself: the stored
preference was honoured for the rows and ignored for the control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAqi2hS2dhZoatqcWoYZQk
This commit is contained in:
Evan Jarrett
2026-09-02 12:45:04 -05:00
co-authored by Claude Opus 5
parent 1253ca15ec
commit a219df9545
3 changed files with 37 additions and 8 deletions
+19 -3
View File
@@ -893,6 +893,16 @@ document.addEventListener('DOMContentLoaded', () => {
// is the fallback for handlers that didn't set the header.
// Opt-out: any ancestor with data-suppress-htmx-toast skips the toast (use
// for components that render their own inline error state).
// A 400 usually carries a reason the server already wrote for the user
// ("File too large (max 3MB)", say). Prefer it over the generic fallback, but
// only when it is short plain text — a rendered error page or a long trace is
// not toast material.
function plainTextReason(xhr) {
const body = xhr && typeof xhr.responseText === 'string' ? xhr.responseText.trim() : '';
if (!body || body.length > 200 || body[0] === '<') return '';
return body;
}
document.body.addEventListener('htmx:responseError', (evt) => {
const elt = evt.detail && evt.detail.elt;
if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return;
@@ -906,6 +916,7 @@ document.body.addEventListener('htmx:responseError', (evt) => {
: status === 404 ? 'Not found'
: status === 429 ? 'Too many requests \u2014 please slow down'
: status >= 500 ? 'Server error \u2014 please try again'
: status === 400 ? (plainTextReason(xhr) || 'Something went wrong')
: 'Something went wrong';
showToast(msg, 'error');
});
@@ -1021,10 +1032,15 @@ async function testWebhook(id) {
credentials: 'include',
});
const text = await resp.text();
if (text.includes('class="success"') || (resp.ok && !text.includes('class="error"'))) {
showToast('Test webhook delivered successfully!', 'success');
// The handler answers 200 either way and carries the outcome in the
// markup, which partials/alert.html emits as "alert alert-error" \u2014
// not the bare class="error" an earlier version of this check looked
// for. Matching the wrong string made the failure branch unreachable,
// so a webhook pointed at a dead URL reported success.
if (text.includes('alert-error') || !resp.ok) {
showToast('Test delivery failed, check the webhook URL', 'error');
} else {
showToast('Test delivery failed \u2014 check the webhook URL', 'error');
showToast('Test webhook delivered successfully!', 'success');
}
} catch {
showToast('Failed to reach server', 'error');
+13 -5
View File
@@ -65,12 +65,20 @@ function applyLayerVisibility(table) {
});
}
function initLayersTables(root) {
// Exported so the repository page's tab controller can run it on content it
// loaded itself. That panel is filled with a plain fetch + innerHTML and
// htmx.process(), which emits no htmx:afterSettle, so neither hook below
// reaches it and the table would otherwise stay unprocessed.
export function initLayersTables(root) {
const scope = root || document;
const tables = scope.querySelectorAll ?
scope.querySelectorAll('.layers-table:not([data-layers-processed])') :
[];
tables.forEach(table => {
if (!scope.querySelectorAll) return;
// Re-seed controls that arrived with this content: a checkbox created
// after DOMContentLoaded has no idea what the stored preference is, and
// leaving it unchecked while the rows it governs are visible makes the UI
// contradict itself.
const show = localStorage.getItem(STORAGE_KEY) === 'true';
scope.querySelectorAll('.show-empty-layers-cb').forEach(cb => { cb.checked = show; });
scope.querySelectorAll('.layers-table:not([data-layers-processed])').forEach(table => {
table.setAttribute('data-layers-processed', '1');
collapseNoHistoryLayers(table);
applyLayerVisibility(table);
+5
View File
@@ -1,6 +1,8 @@
// Repository page: owner markdown editor, tag sort/filter, tab controller.
// Extracted from repository.html to keep the page shell light.
import { initLayersTables } from './layers.js';
// ----------------------------------------
// Owner markdown editor (no-op if not owner / not on page)
// ----------------------------------------
@@ -236,6 +238,9 @@ function initTabController() {
old.parentNode.replaceChild(s, old);
});
if (typeof window.htmx !== 'undefined') window.htmx.process(target);
// htmx.process() does not emit htmx:afterSettle, so the layer
// table enhancements have to be kicked off by hand here.
initLayersTables(target);
})
.catch(err => {
// Clear state immediately so the retry button (or another