feat(settings): save feedback — success/error toasts, disabled buttons; fix scan-form raw-JSON swap
Release / build-and-push (push) Successful in 4m35s
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:
1 parent
7b9e8e4fc2
commit
9f39e2efd8
7 files changed
+357
-24
No files matched your search
@@ -0,0 +1,160 @@
|
|||||||
|
import { test, expect, type APIRequestContext, type Page } from "@playwright/test";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
const BASE = "http://localhost:8765";
|
||||||
|
const STAMP = Date.now();
|
||||||
|
const EMAIL = `e2e-setfb-${STAMP}@example.com`;
|
||||||
|
const USERNAME = `e2e-setfb-${STAMP}`;
|
||||||
|
const PASSWORD = "E2e-Playwright-2026!";
|
||||||
|
|
||||||
|
// Settings-page save feedback: every save surface must acknowledge the
|
||||||
|
// click (toast) and the scan form must NOT swap the handler's JSON body
|
||||||
|
// over its card (the pre-toast behavior — raw JSON replaced the form).
|
||||||
|
//
|
||||||
|
// Dev-state safety: every success save re-submits the value ALREADY in
|
||||||
|
// the input (read from the page first), so nothing in the shared dev DB
|
||||||
|
// changes; the error-path request is rejected server-side before any
|
||||||
|
// write.
|
||||||
|
|
||||||
|
let token = "";
|
||||||
|
let page = null as Page | null;
|
||||||
|
// system_config.updated_by audit restore: the config save handler stamps
|
||||||
|
// the requesting admin's id onto system_config rows. The harness cleanup
|
||||||
|
// deletes transient example.com users, and this FK (no cascade) would
|
||||||
|
// block the delete and fail unrelated integration tests. Capture before
|
||||||
|
// any save, restore after.
|
||||||
|
let e2eUserID = "";
|
||||||
|
let origUpdatedBy = "";
|
||||||
|
|
||||||
|
function psql(sql: string): string {
|
||||||
|
const flat = sql.replace(/\s+/g, " ");
|
||||||
|
return execSync(
|
||||||
|
`docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`,
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(request: APIRequestContext): Promise<string> {
|
||||||
|
const res = await request.post("/api/auth/login", {
|
||||||
|
data: { login: EMAIL, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect(res.status(), "login should succeed").toBe(200);
|
||||||
|
return (await res.json()).access_token as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
test.describe("Settings save feedback (toasts + disabled buttons)", () => {
|
||||||
|
test.beforeAll(async ({ browser, request }) => {
|
||||||
|
// Fresh user, promoted to admin in the dev DB (the harness cleanup
|
||||||
|
// sweeps transient example.com users afterwards).
|
||||||
|
const reg = await request.post("/api/auth/register", {
|
||||||
|
data: { email: EMAIL, username: USERNAME, password: PASSWORD },
|
||||||
|
});
|
||||||
|
expect([201, 200, 409]).toContain(reg.status());
|
||||||
|
psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`);
|
||||||
|
token = await apiLogin(request);
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
e2eUserID = psql(`SELECT id FROM users WHERE email='${EMAIL}'`);
|
||||||
|
origUpdatedBy = psql(`SELECT COALESCE(updated_by::text, '') FROM system_config WHERE key='base_url'`);
|
||||||
|
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.context().addCookies([{ name: "token", value: token, url: BASE }]);
|
||||||
|
await page.goto("/admin/settings");
|
||||||
|
await expect(page.locator("h1")).toHaveText("System Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page?.close();
|
||||||
|
// Hand system_config.updated_by back to whoever held it (or NULL),
|
||||||
|
// so the transient user can be swept.
|
||||||
|
if (e2eUserID) {
|
||||||
|
const restore = origUpdatedBy
|
||||||
|
? `'${origUpdatedBy}'::uuid`
|
||||||
|
: "NULL";
|
||||||
|
psql(`UPDATE system_config SET updated_by = ${restore} WHERE updated_by = '${e2eUserID}'::uuid`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Base URL save shows a success toast — twice (twin-form drift pin)", async () => {
|
||||||
|
const input = page.locator("#settings-form input[name='base_url']");
|
||||||
|
await expect(input).toBeVisible();
|
||||||
|
|
||||||
|
// First save goes through the templ form…
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// …the second through the Go-source twin the handler swaps in after
|
||||||
|
// the first save. If the twin ever drops data-settings-save (the
|
||||||
|
// handlers→templates import cycle is why the twin exists), THIS is
|
||||||
|
// the assertion that fails.
|
||||||
|
await page.waitForTimeout(300); // let the swap settle
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
await expect(page.getByText("Settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Scan settings save shows a toast and never swaps raw JSON over the card", async () => {
|
||||||
|
const section = page.locator("#scan-settings-section");
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
|
||||||
|
await section.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Scan settings saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression: the scan handler responds JSON; with the old
|
||||||
|
// hx-swap="outerHTML" the card was replaced by the literal JSON text.
|
||||||
|
await expect(section).toBeVisible();
|
||||||
|
await expect(section.locator("form")).toBeVisible();
|
||||||
|
const body = await page.locator("body").innerText();
|
||||||
|
expect(body).not.toContain("scan settings updated successfully");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Tunable row save shows a toast and the per-row status span", async () => {
|
||||||
|
const row = page.locator("form[data-settings-save='tunable']").first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
const key = await row.locator("input[name='key']").getAttribute("value");
|
||||||
|
expect(key).toBeTruthy();
|
||||||
|
|
||||||
|
await row.locator("button[type='submit']").click();
|
||||||
|
await expect(page.getByText("Setting saved", { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// Server-driven per-row status (kept alongside the toast; it carries
|
||||||
|
// the "restart required" nuance).
|
||||||
|
await expect(page.locator(`#status-${key}`)).toContainText("Saved", {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invalid value shows an error toast and the form survives", async () => {
|
||||||
|
// Inject a genuinely invalid option and select it: setting a select
|
||||||
|
// to a value with no matching option silently serializes as NO field
|
||||||
|
// at all (no selected option → nothing submitted), which would save
|
||||||
|
// fine instead of failing. With a real option present, the server
|
||||||
|
// rejects with 400 {"error":"invalid timezone"} before any write.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const select = document.getElementById("default_timezone") as HTMLSelectElement;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "Not/AZone";
|
||||||
|
opt.textContent = "E2E invalid";
|
||||||
|
select.appendChild(opt);
|
||||||
|
select.value = "Not/AZone";
|
||||||
|
});
|
||||||
|
await page.locator("#settings-form button[type='submit']").first().click();
|
||||||
|
|
||||||
|
await expect(page.getByText("Save failed: invalid timezone")).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
// htmx does not swap non-2xx bodies: the form is intact.
|
||||||
|
await expect(page.locator("#settings-form")).toBeVisible();
|
||||||
|
await expect(page.locator("#settings-form input[name='base_url']")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -489,12 +489,18 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
defaultTimezone = tz
|
defaultTimezone = tz
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render success message with updated form
|
// Render success message with updated form. NOTE: this Go-source
|
||||||
|
// copy is the htmx-swapped twin of the templ form in
|
||||||
|
// templates/admin_settings.templ (handlers cannot import templates:
|
||||||
|
// templates/utils.go imports handlers types — cycle). It MUST keep
|
||||||
|
// the twin's functional attributes — id, hx-*, data-settings-save,
|
||||||
|
// hx-disabled-elt — or save feedback silently dies after the first
|
||||||
|
// swap. e2e/settings-feedback.spec.ts saves twice to pin this.
|
||||||
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||||
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
||||||
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
|
<p style="color: var(--text-success);">✅ Settings saved successfully!</p>
|
||||||
</div>
|
</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 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">Base URL</h3>
|
||||||
|
|
||||||
@@ -513,7 +519,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,7 +557,7 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium" hx-disabled-elt="this">
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
|||||||
<span>{ errorMessage }</span>
|
<span>{ errorMessage }</span>
|
||||||
</div>
|
</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="card p-6">
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div class="flex items-center gap-2 mb-6">
|
||||||
@Icon("globe", "h-5 w-5 shrink-0")
|
@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>
|
<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>
|
||||||
<div class="mt-6 flex justify-end">
|
<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")
|
@Icon("save", "h-4 w-4")
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -128,9 +128,9 @@ templ ScanSettingsSection(scanSettings ScanSettingsData) {
|
|||||||
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Scanning</h3>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
data-settings-save="scan"
|
||||||
hx-put="/admin/settings/scan"
|
hx-put="/admin/settings/scan"
|
||||||
hx-target="#scan-settings-section"
|
hx-swap="none"
|
||||||
hx-swap="outerHTML"
|
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-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>
|
<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>
|
||||||
<div class="flex justify-end">
|
<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")
|
@Icon("save", "h-4 w-4")
|
||||||
Save Scan Settings
|
Save Scan Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -219,6 +219,7 @@ templ TunableSettingRow(e SettingEntry) {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
class="flex items-center gap-2 shrink-0"
|
class="flex items-center gap-2 shrink-0"
|
||||||
|
data-settings-save="tunable"
|
||||||
hx-put="/admin/settings/tunable"
|
hx-put="/admin/settings/tunable"
|
||||||
hx-target={ "#status-" + e.Key }
|
hx-target={ "#status-" + e.Key }
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -251,7 +252,7 @@ templ TunableSettingRow(e SettingEntry) {
|
|||||||
class="input py-1.5 text-sm w-40"
|
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")
|
@Icon("save", "h-3.5 w-3.5")
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -274,3 +275,5 @@ templ AboutSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<form id=\"settings-form\" 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\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<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\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><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\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" placeholder=\"https://books.example.com\" class=\"input\" required><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\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -484,7 +484,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Scanning</h3></div><form hx-put=\"/admin/settings/scan\" hx-target=\"#scan-settings-section\" hx-swap=\"outerHTML\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<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-swap=\"none\"><div class=\"space-y-4\"><div class=\"flex items-center justify-between gap-4\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\">Auto-Scan</label><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Watch libraries for file changes on startup</p></div><label class=\"relative inline-flex items-center cursor-pointer shrink-0\"><input type=\"checkbox\" name=\"auto_scan_enabled\" value=\"true\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -507,7 +507,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><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\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" min=\"1\" max=\"3600\" class=\"input\" required><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\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -698,14 +698,14 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div><form class=\"flex items-center gap-2 shrink-0\" data-settings-save=\"tunable\" hx-put=\"/admin/settings/tunable\" hx-target=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 224, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -718,7 +718,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 227, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 228, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -761,7 +761,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 237, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -779,7 +779,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 239, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 240, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -798,7 +798,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 242, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 243, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -821,7 +821,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 250, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 251, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -832,7 +832,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<button type=\"submit\" class=\"btn btn-secondary px-3 py-1.5 text-sm\" hx-disabled-elt=\"this\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -847,7 +847,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 259, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 260, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -900,7 +900,7 @@ func AboutSection() templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 273, Col: 134}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import "./profile-modal";
|
|||||||
import "./register";
|
import "./register";
|
||||||
import "./search";
|
import "./search";
|
||||||
import "./series";
|
import "./series";
|
||||||
|
import "./settings";
|
||||||
import "./setup";
|
import "./setup";
|
||||||
import "./stats";
|
import "./stats";
|
||||||
import "./storage";
|
import "./storage";
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { extractSaveError, saveLabelFor } from "./settings";
|
||||||
|
|
||||||
|
// The settings page's save feedback keys off the data-settings-save
|
||||||
|
// attribute stamped on each form in templates/admin_settings.templ. If a
|
||||||
|
// form loses the attribute (or a new save surface is added without one),
|
||||||
|
// its saves silently regress to "no indicator" — the bug this module
|
||||||
|
// exists to fix. These pins make that drift a test failure.
|
||||||
|
|
||||||
|
function el(attrs: Record<string, string>): Element {
|
||||||
|
const node = document.createElement("form");
|
||||||
|
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("saveLabelFor", () => {
|
||||||
|
it("maps every stamped form to its success label", () => {
|
||||||
|
expect(saveLabelFor(el({ "data-settings-save": "settings" }))).toBe("Settings saved");
|
||||||
|
expect(saveLabelFor(el({ "data-settings-save": "scan" }))).toBe("Scan settings saved");
|
||||||
|
expect(saveLabelFor(el({ "data-settings-save": "tunable" }))).toBe("Setting saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-settings elements (listener filter)", () => {
|
||||||
|
expect(saveLabelFor(document.createElement("div"))).toBeNull();
|
||||||
|
expect(saveLabelFor(null)).toBeNull();
|
||||||
|
expect(saveLabelFor(undefined)).toBeNull();
|
||||||
|
expect(saveLabelFor(el({ "data-settings-save": "unknown-kind" }))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractSaveError", () => {
|
||||||
|
function xhrWith(responseText: string, status: number): XMLHttpRequest {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
Object.defineProperty(xhr, "responseText", { value: responseText });
|
||||||
|
Object.defineProperty(xhr, "status", { value: status });
|
||||||
|
return xhr;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("surfaces the JSON error field the config endpoints return", () => {
|
||||||
|
expect(extractSaveError(xhrWith(`{"error":"invalid timezone"}`, 400))).toBe("invalid timezone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the message field when present", () => {
|
||||||
|
expect(extractSaveError(xhrWith(`{"message":"boom"}`, 500))).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never puts markup in a toast (tunable endpoint returns an HTML span on 400)", () => {
|
||||||
|
const msg = extractSaveError(
|
||||||
|
xhrWith(`<span style="color: var(--status-danger);">value rejected</span>`, 400),
|
||||||
|
);
|
||||||
|
expect(msg).toBe("Save failed (HTTP 400)");
|
||||||
|
expect(msg).not.toContain("<");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to a status line for empty bodies and network failures", () => {
|
||||||
|
expect(extractSaveError(xhrWith("", 0))).toBe("Save failed (HTTP 0)");
|
||||||
|
expect(extractSaveError(undefined)).toBe("Save failed (HTTP 0)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Admin settings page: save feedback.
|
||||||
|
//
|
||||||
|
// The page's three save surfaces (Base URL form, scan settings form,
|
||||||
|
// per-row tunable forms) are htmx-driven; before this module none of
|
||||||
|
// them gave visible confirmation — the Base URL form re-rendered into
|
||||||
|
// an identical-looking form, and the scan form swapped raw JSON text
|
||||||
|
// over its card (fixed server-side by hx-swap="none" + this toast).
|
||||||
|
// Toasts are the sanctioned client-local island, so the notification
|
||||||
|
// lives here; no inline scripts (CSP hash discipline).
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
|
// Map a settings save form to its success-toast label. Returns null for
|
||||||
|
// elements that are not settings save forms, so the delegated listener
|
||||||
|
// can filter in one call. Keyed by the data-settings-save attribute
|
||||||
|
// stamped on each form in templates/admin_settings.templ.
|
||||||
|
export function saveLabelFor(elt: EventTarget | null | undefined): string | null {
|
||||||
|
const form = elt instanceof Element ? elt : null;
|
||||||
|
const kind = form?.getAttribute("data-settings-save");
|
||||||
|
switch (kind) {
|
||||||
|
case "settings":
|
||||||
|
return "Settings saved";
|
||||||
|
case "scan":
|
||||||
|
return "Scan settings saved";
|
||||||
|
case "tunable":
|
||||||
|
return "Setting saved";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a human-readable error out of a failed save response. The config
|
||||||
|
// endpoints return JSON `{"error": "..."}`; the tunable endpoint returns
|
||||||
|
// an HTML span on 400 — markup must never reach a toast, so non-JSON
|
||||||
|
// bodies degrade to the status line.
|
||||||
|
export function extractSaveError(xhr: XMLHttpRequest | undefined): string {
|
||||||
|
const text = (xhr?.responseText || "").trim();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as { error?: string; message?: string };
|
||||||
|
if (parsed.error) return parsed.error;
|
||||||
|
if (parsed.message) return parsed.message;
|
||||||
|
} catch {
|
||||||
|
// HTML or plain-text body — fall through.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `Save failed (HTTP ${xhr?.status || 0})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The element htmx issued the request FROM. htmx 2.x dispatches request
|
||||||
|
// lifecycle events with detail.elt set to the element the event was
|
||||||
|
// dispatched on — for FORM submissions that is an ancestor wrapper, and
|
||||||
|
// the real requesting element lives on detail.requestConfig.elt
|
||||||
|
// (verified empirically: afterRequest on the settings forms reports
|
||||||
|
// elt=DIV wrapper, requestConfig.elt=FORM). Element-triggered requests
|
||||||
|
// (htmx.trigger on the search input) set both to the same element, so
|
||||||
|
// prefer requestConfig.elt and fall back to elt.
|
||||||
|
function requestingElement(
|
||||||
|
detail:
|
||||||
|
| {
|
||||||
|
requestConfig?: { elt?: EventTarget };
|
||||||
|
elt?: EventTarget;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): Element | null {
|
||||||
|
const rc = detail?.requestConfig?.elt;
|
||||||
|
if (rc instanceof Element) return rc;
|
||||||
|
const elt = detail?.elt;
|
||||||
|
return elt instanceof Element ? elt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireSettingsSaveFeedback(): void {
|
||||||
|
// htmx fires lifecycle events on the REQUESTING element; body-level
|
||||||
|
// delegation covers all three forms (and rows swapped in later).
|
||||||
|
document.body.addEventListener("htmx:afterRequest", (evt) => {
|
||||||
|
const e = evt as CustomEvent<{
|
||||||
|
elt: EventTarget;
|
||||||
|
requestConfig?: { elt?: EventTarget };
|
||||||
|
successful: boolean;
|
||||||
|
xhr?: XMLHttpRequest;
|
||||||
|
}>;
|
||||||
|
const label = saveLabelFor(requestingElement(e.detail));
|
||||||
|
if (!label) return;
|
||||||
|
if (e.detail.successful) {
|
||||||
|
showToast(label, "success");
|
||||||
|
} else {
|
||||||
|
showToast(`Save failed: ${extractSaveError(e.detail.xhr)}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A request that never gets a response (network down, server restart)
|
||||||
|
// fires sendError, not afterRequest — without this the button just
|
||||||
|
// re-enables silently and the user can't tell the save never landed.
|
||||||
|
document.body.addEventListener("htmx:sendError", (evt) => {
|
||||||
|
const e = evt as CustomEvent<{ elt: EventTarget; requestConfig?: { elt?: EventTarget } }>;
|
||||||
|
if (!saveLabelFor(requestingElement(e.detail))) return;
|
||||||
|
showToast("Save failed: network error", "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bundle is a deferred module script: the DOM is parsed when this
|
||||||
|
// runs, so direct wiring matches the other modules (admin.ts etc.).
|
||||||
|
wireSettingsSaveFeedback();
|
||||||
Reference in new issue
Block a user