feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
Release / build-and-push (push) Successful in 4m35s

None of the three save surfaces on the admin settings page gave usable
feedback: the Base URL form re-rendered into an identical-looking form
(its only indicator was the Go-source twin's banner — markup that
diverged from the templ original), the scan form was WORSE — its handler
responds JSON, so htmx's outerHTML swap replaced the whole scan card
with literal JSON text — and the tunable rows showed only a tiny
per-row label.

Client side (toasts are the sanctioned client-local island; no new
inline scripts, CSP unchanged):

- web/src/settings.ts: one delegated htmx:afterRequest/sendError
  listener keyed on a data-settings-save attribute stamped on each
  form. Success → green toast per surface ("Settings saved" / "Scan
  settings saved" / "Setting saved"); failure → error toast carrying
  the server's JSON error field, degrading to a status line for the
  tunable endpoint's HTML error body (markup must never reach a toast).
  Network-level failures (htmx:sendError) toast instead of silently
  re-enabling the button.
- htmx 2.x quirk handled: for FORM submissions, afterRequest's
  detail.elt is the event-dispatch ancestor, not the form — the
  requesting element lives on detail.requestConfig.elt (verified
  empirically; saveLabelFor reads the right one).
- hx-disabled-elt="this" on all save buttons: the click is
  acknowledged and double-clicks cannot double-save.

Server side:

- Scan form: hx-swap="none" (the inputs already hold the saved values;
  nothing needs re-rendering) — kills the JSON-over-the-card swap.
- The Base URL form's save response is a Go-source copy of the templ
  form (handlers cannot import templates: templates/utils.go imports
  handlers types). The twin now carries the functional attributes
  (data-settings-save, hx-disabled-elt) so feedback survives the swap,
  with a drift-warning comment; the double-save e2e pins this.

Dev-DB safety: every success save in the spec re-submits the value
already on the page (zero drift), and the spec restores
system_config.updated_by after itself — the save handler stamps the
requesting admin's id there, and that FK (no cascade) otherwise blocks
the integration harness's transient-user sweep (it broke
TestUpdateProfileAdminMode/TestDeleteUserConsolidated on this run's
first pass; pollution cleaned up by hand and now self-restoring).

Tests: web/src/settings.test.ts pins the form→label map and the
error-extraction contract (including the no-markup-in-toast rule);
e2e/settings-feedback.spec.ts drives all three surfaces through the
real admin UI including the error path and the twin-drift double-save.
This commit is contained in:
John O'Keefe committed 2026-10-28 00:22:00 -04:00
1 parent 7b9e8e4fc2
commit 9f39e2efd8
7 files changed
+357 -24

No files matched your search

+9 -6
View File
@@ -34,7 +34,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<span>{ errorMessage }</span>
</div>
}
<form id="settings-form" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<form id="settings-form" data-settings-save="settings" hx-put="/api/system/config" hx-target="#settings-form" hx-swap="outerHTML">
<div class="card p-6">
<div class="flex items-center gap-2 mb-6">
@Icon("globe", "h-5 w-5 shrink-0")
@@ -53,7 +53,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.</p>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
@Icon("save", "h-4 w-4")
Save Settings
</button>
@@ -128,9 +128,9 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
</div>
<form
data-settings-save="scan"
hx-put="/admin/settings/scan"
hx-target="#scan-settings-section"
hx-swap="outerHTML"
hx-swap="none"
>
<div class="space-y-4">
<div class="flex items-center justify-between gap-4">
@@ -164,7 +164,7 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
<p class="text-sm mt-2" style="color: var(--text-secondary)">How often to poll libraries for changes (1–3600 seconds). Default: 60.</p>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" hx-disabled-elt="this">
@Icon("save", "h-4 w-4")
Save Scan Settings
</button>
@@ -219,6 +219,7 @@ templ TunableSettingRow(e SettingEntry) {
</div>
<form
class="flex items-center gap-2 shrink-0"
data-settings-save="tunable"
hx-put="/admin/settings/tunable"
hx-target={ "#status-" + e.Key }
hx-swap="innerHTML"
@@ -251,7 +252,7 @@ templ TunableSettingRow(e SettingEntry) {
class="input py-1.5 text-sm w-40"
/>
}
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm">
<button type="submit" class="btn btn-secondary px-3 py-1.5 text-sm" hx-disabled-elt="this">
@Icon("save", "h-3.5 w-3.5")
Save
</button>
@@ -274,3 +275,5 @@ templ AboutSection() {
</div>
</div>
}