feat(settings): auto-save preferences instead of draft + Save

Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
This commit is contained in:
2026-07-18 01:44:25 +02:00
parent 49739da925
commit c855924fdc
5 changed files with 126 additions and 188 deletions
+26 -37
View File
@@ -1,62 +1,51 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { openApp, gotoPage } from "./helpers";
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of
// the behaviors the god-component refactor must preserve exactly.
test.describe("settings dirty-save", () => {
// Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
// it; leaving Settings after a change is no longer guarded.
test.describe("settings auto-save", () => {
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
async function putPrefs(page: import("@playwright/test").Page) {
return page.waitForResponse(
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
const putPrefs = (page: Page) =>
page.waitForResponse(
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
);
}
test("edit raises the bar and Save persists via PUT /me/preferences", async ({ page }) => {
test("changing a setting auto-saves and shows the saved indicator", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
const bar = page.getByTestId("draft-save-bar");
const status = page.getByTestId("draft-save-status");
const toggle = page.getByRole("switch", { name: TOGGLE });
const indicator = page.getByTestId("settings-save-indicator");
// Clean: the bar renders nothing.
await expect(bar).toHaveCount(0);
// Idle: nothing to save, so no indicator (and no draft Save bar).
await expect(indicator).toHaveCount(0);
// Edit → the bar appears.
await toggle.click();
await expect(bar).toBeVisible();
// Save → the PUT fires and the status flips to "saved".
const [saved] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
// Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
const [saved] = await Promise.all([putPrefs(page), toggle.click()]);
expect(saved.ok()).toBeTruthy();
await expect(status).toHaveAttribute("data-state", "saved");
await expect(indicator).toHaveAttribute("data-state", "saved");
// Restore the baseline (toggle back and save) so the shared account stays clean. Assert the
// restore's own PUT (not the lingering "saved" from the first save, which shows for 2.5s).
await toggle.click();
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(restored.ok()).toBeTruthy();
// Restore the baseline so the shared account stays clean.
await Promise.all([putPrefs(page), toggle.click()]);
});
test("leaving with unsaved changes is guarded", async ({ page }) => {
test("a changed setting no longer blocks leaving the page", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
await page.getByRole("switch", { name: TOGGLE }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
const toggle = page.getByRole("switch", { name: TOGGLE });
await Promise.all([putPrefs(page), toggle.click()]);
// Navigating away raises the confirm guard instead of leaving.
// No unsaved-changes prompt — navigating away just lands on the feed.
await page.getByTestId("nav-feed").click();
await expect(page.getByText("You have unsaved settings")).toBeVisible();
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Cancel → stay on Settings, still dirty.
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
// Discard → clean, so the account is left at baseline.
await page.getByTestId("draft-discard").click();
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
// Restore the baseline (back on Settings).
await gotoPage(page, "settings");
await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
});
});