fix(settings): one save model and one confirm per card

The Telegram, ntfy, webhook and email cards saved their enable switch on
tap and the rest of the form on Save; the switch is now part of the form
and the one Save sends it. The display selects confirm a change with a
toast. Destructive confirmations in Settings go through ConfirmButton or
ConfirmDialog with the destructive style. Two structural guards hold both
rules: a card may not mix instant and explicit saves, and no settings file
reaches for a raw AlertDialog without a written reason.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:42:53 +02:00
1 parent 93198017d6
commit 3ebef19ac3
32 files changed
+830 -1135

No files matched your search

+2 -18
View File
@@ -270,21 +270,6 @@ async function saveCard(
return (await settled).status();
}
/** Flip a channel card's enable switch and wait for the route to answer. */
async function toggleCard(
page: Page,
switchId: string,
endpoint: string,
): Promise<number> {
const settled = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "PUT",
);
await page.locator(switchId).click();
return (await settled).status();
}
/**
* Configure both channels the way a person would: email on with a recipient,
* ntfy saved and left off. Everything here happens on the settings surface.
@@ -292,11 +277,10 @@ async function toggleCard(
async function configureChannels(page: Page): Promise<void> {
await openChannels(page);
// The enable switch is a field of the form: it saves with the one Save.
await fillField(page, "#email-recipient", EMAIL_RECIPIENT);
await page.locator("#email-toggle").click();
expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200);
expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe(
200,
);
await expect(page.locator("#email-toggle")).toHaveAttribute(
"data-state",
"checked",
@@ -110,7 +110,7 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
@@ -129,13 +129,13 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
@@ -156,7 +156,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/mcp-section.tsx",
@@ -168,7 +168,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
@@ -214,7 +214,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/coach-memory-section.tsx",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
@@ -348,7 +348,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
"src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
@@ -389,7 +388,6 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
// The one that used to sit inside `account-section/index.tsx` — the card
// moved to Settings → Security, the line moved with it.
"src/components/settings/password-card.tsx": { "text-success": 1 },
@@ -397,7 +395,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
"src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -407,7 +404,6 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
@@ -21,6 +21,7 @@ describe("channel form dirty state", () => {
headerName: "X-Gotify-Key",
headerValue: "",
format: "gotify" as const,
enabled: false,
};
it("webhook: a form that matches the saved config is clean", () => {
@@ -56,7 +57,12 @@ describe("channel form dirty state", () => {
it("ntfy: server, topic and a typed auth token count", () => {
const saved = { serverUrl: "https://ntfy.sh", topic: "t" };
const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
const form = {
serverUrl: "https://ntfy.sh",
topic: "t",
authToken: "",
enabled: false,
};
expect(isNtfyFormDirty(form, saved)).toBe(false);
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
@@ -67,39 +73,86 @@ describe("channel form dirty state", () => {
it("email: the recipient counts", () => {
expect(
isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(false);
expect(
isEmailFormDirty({ recipient: "b@x.io" }, { recipient: "a@x.io" }),
isEmailFormDirty(
{ recipient: "b@x.io", enabled: false },
{ recipient: "a@x.io" },
),
).toBe(true);
});
it("telegram: a typed bot token or a changed chat id counts; whitespace alone does not", () => {
expect(
isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
).toBe(false);
expect(
isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1" },
{ botToken: " ", chatId: "", enabled: false },
{ chatId: null },
),
).toBe(false);
expect(
isTelegramFormDirty(
{ botToken: "123:abc", chatId: "1", enabled: false },
{ chatId: "1" },
),
).toBe(true);
expect(
isTelegramFormDirty({ botToken: "", chatId: "2" }, { chatId: "1" }),
isTelegramFormDirty(
{ botToken: "", chatId: "2", enabled: false },
{ chatId: "1" },
),
).toBe(true);
});
it("a flipped enable switch is a change on every channel, since it saves with the form", () => {
expect(
isWebhookFormDirty({ ...webhookForm, enabled: true }, webhookSaved),
).toBe(true);
expect(
isNtfyFormDirty(
{ serverUrl: "s", topic: "t", authToken: "", enabled: true },
{ serverUrl: "s", topic: "t", enabled: false },
),
).toBe(true);
expect(
isEmailFormDirty(
{ recipient: "a@x.io", enabled: false },
{ recipient: "a@x.io", enabled: true },
),
).toBe(true);
expect(
isTelegramFormDirty(
{ botToken: "", chatId: "1", enabled: true },
{ chatId: "1", enabled: true },
),
).toBe(false);
});
it("nothing loaded yet is never a change", () => {
expect(isWebhookFormDirty(webhookForm, undefined)).toBe(false);
expect(
isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
isNtfyFormDirty(
{ serverUrl: "", topic: "", authToken: "x", enabled: true },
undefined,
),
).toBe(false);
expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
false,
);
expect(
isTelegramFormDirty(
{ botToken: "x", chatId: "", enabled: true },
undefined,
),
).toBe(false);
});
});
@@ -0,0 +1,77 @@
/**
* One confirm style across Settings.
*
* A destructive act (delete, revoke, remove, reset, sign out) confirms through
* `ConfirmButton` / `ConfirmDialog` (`src/components/ui/confirm-button.tsx`),
* which owns the dialog's anatomy and wears the destructive style when the
* caller says the act is destructive. A hand-built `AlertDialog` is how the
* same question ended up styled three different ways on neighbouring cards.
*
* So no file under `src/components/settings` imports the raw `AlertDialog`
* unless it is listed below with the reason the primitive does not fit.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(process.cwd(), "src", "components", "settings");
const ALLOWED: Readonly<Record<string, string>> = {
"security-section/existing-factor-reauth-dialog.tsx":
"a re-authentication form, not a destructive confirm",
"security-section/passkey-list-section.tsx":
"the add-passkey re-authentication form; the delete confirm uses ConfirmButton",
"security-section/totp-card.tsx":
"the disable confirm asks for a current code and stays open until the server answers; both actions wear the destructive style",
"mcp-section.tsx":
"the connection and token revoke confirms; the MCP card is reshaped separately",
};
/** Owned by the integration cards, which follow their own card kit. */
const ALLOWED_PREFIXES: readonly string[] = ["integrations/"];
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return name === "__tests__" ? [] : walk(path);
}
return /\.tsx?$/.test(name) ? [path] : [];
});
}
const IMPORTS_ALERT_DIALOG = /from\s+["']@\/components\/ui\/alert-dialog["']/;
describe("settings confirm style", () => {
const files = walk(ROOT);
it("scans the settings tree", () => {
expect(files.length).toBeGreaterThan(50);
});
it("imports the raw AlertDialog only where the primitive does not fit", () => {
const offenders = files
.map((path) => relative(ROOT, path))
.filter((rel) =>
IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
)
.filter(
(rel) =>
!(rel in ALLOWED) &&
!ALLOWED_PREFIXES.some((prefix) => rel.startsWith(prefix)),
);
expect(
offenders,
"Confirm a destructive act through ConfirmButton / ConfirmDialog, or list the file here with the reason",
).toEqual([]);
});
it("lists no file that no longer needs the exemption", () => {
const stale = Object.keys(ALLOWED).filter(
(rel) =>
!IMPORTS_ALERT_DIALOG.test(readFileSync(join(ROOT, rel), "utf8")),
);
expect(stale).toEqual([]);
});
});
@@ -121,60 +121,27 @@ describe("notification channel cards", () => {
);
it.each([
["webhook", <WebhookCard key="webhook" isAuthenticated />, 1],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />, 1],
["email", <EmailCard key="email" isAuthenticated />, 1],
["telegram", <TelegramCard key="telegram" isAuthenticated />, 0],
["webhook", <WebhookCard key="webhook" isAuthenticated />],
["ntfy", <NtfyCard key="ntfy" isAuthenticated />],
["email", <EmailCard key="email" isAuthenticated />],
["telegram", <TelegramCard key="telegram" isAuthenticated />],
] as const)(
"%s switch invokes the enable-only mutation rather than the full save",
async (channel, card, toggleMutationIndex) => {
render(card);
await mocks.mutationOptions[toggleMutationIndex]!.mutationFn(true);
expect(mocks.apiFetchRaw).toHaveBeenCalledTimes(1);
expect(mocks.apiFetchRaw).toHaveBeenCalledWith(
`/api/settings/${channel}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
},
);
},
);
it.each([
[<WebhookCard key="webhook" isAuthenticated />, 1, ["settings", "webhook"]],
[<NtfyCard key="ntfy" isAuthenticated />, 1, ["settings", "ntfy"]],
[<EmailCard key="email" isAuthenticated />, 1, ["settings", "email"]],
[
<TelegramCard key="telegram" isAuthenticated />,
0,
["telegram", "settings"],
],
] as const)(
"rolls each visible switch back to its previous server state on error",
async (card, toggleMutationIndex, queryKey) => {
const previous = { enabled: false, persistedConfig: "unchanged" };
mocks.queryClient.getQueryData.mockReturnValue(previous);
render(card);
const toggle = mocks.mutationOptions[toggleMutationIndex]!;
const context = await toggle.onMutate!(true);
const optimisticUpdater = mocks.queryClient.setQueryData.mock
.calls[0]![1] as (current: typeof previous) => typeof previous;
expect(optimisticUpdater(previous)).toEqual({
...previous,
enabled: true,
});
toggle.onError!(new Error("save failed"), true, context);
expect(mocks.queryClient.setQueryData).toHaveBeenLastCalledWith(
queryKey,
previous,
);
"%s enable switch is a form field: it writes nothing on its own",
async (_channel, card) => {
const html = render(card);
// Rendering the switch writes nothing, and the card mounts no
// switch-only mutation: every write it can make is the full form.
expect(html).toContain('role="switch"');
expect(mocks.apiFetchRaw).not.toHaveBeenCalled();
for (const mutation of mocks.mutationOptions) {
await mutation.mutationFn(true);
}
for (const [, init] of mocks.apiFetchRaw.mock.calls as Array<
[string, { body: string }]
>) {
const body = JSON.parse(init.body) as Record<string, unknown>;
expect(Object.keys(body).length).toBeGreaterThan(1);
}
},
);
@@ -185,9 +152,9 @@ describe("notification channel cards", () => {
expect(html).toMatch(/<option value="generic" selected="">/);
expect(html).toContain('<option value="gotify">');
// The full save is the first mutation of the last render, whose closure
// holds the values seeded from the server.
const save = mocks.mutationOptions[mocks.mutationOptions.length - 2]!;
// The full save is the mutation of the last render, whose closure holds
// the values seeded from the server, the enable switch included.
const save = mocks.mutationOptions.at(-1)!;
await save.mutationFn(false);
const [, init] = mocks.apiFetchRaw.mock.calls[0] as [
@@ -1,93 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api/api-fetch", () => ({
apiFetchRaw: vi.fn(),
}));
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "../notification-channel-toggle";
beforeEach(() => {
vi.clearAllMocks();
});
describe("notification channel enable-only mutation", () => {
it.each([
"/api/settings/webhook",
"/api/settings/ntfy",
"/api/settings/email",
"/api/settings/telegram",
])("sends only enabled to %s", async (endpoint) => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(JSON.stringify({ data: { saved: true } }), { status: 200 }),
);
await persistChannelEnabled(endpoint, true, "save failed");
expect(apiFetchRaw).toHaveBeenCalledWith(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(
JSON.parse(vi.mocked(apiFetchRaw).mock.calls[0]![1]!.body as string),
).toEqual({ enabled: true });
});
it("changes only cached enabled state and leaves configuration fields intact", () => {
const client = new QueryClient();
const key = ["settings", "webhook"] as const;
const settings = {
enabled: false,
url: "https://saved.example/hook",
topic: "saved-topic",
recipient: "saved@example.com",
hasHeaderValue: true,
};
client.setQueryData(key, settings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(previous).toEqual(settings);
expect(client.getQueryData(key)).toEqual({ ...settings, enabled: true });
});
it("restores the exact previous cached settings after a failed toggle", () => {
const client = new QueryClient();
const key = ["settings", "telegram"] as const;
const previousSettings = {
enabled: false,
hasBotToken: true,
chatId: "123456",
};
client.setQueryData(key, previousSettings);
const previous = optimisticallySetChannelEnabled(client, key, true);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(true);
rollbackChannelEnabled(client, key, previous);
expect(client.getQueryData(key)).toEqual(previousSettings);
expect(client.getQueryData<{ enabled: boolean }>(key)?.enabled).toBe(false);
});
it("surfaces the route error so the card mutation can roll back", async () => {
vi.mocked(apiFetchRaw).mockResolvedValue(
new Response(
JSON.stringify({ error: "Stored configuration is missing" }),
{
status: 422,
},
),
);
await expect(
persistChannelEnabled("/api/settings/ntfy", true, "save failed"),
).rejects.toThrow("Stored configuration is missing");
});
});
@@ -0,0 +1,256 @@
/**
* One save model per card.
*
* A settings card either saves each control the moment it changes (switches,
* selects, segmented choices) or collects a multi-field draft behind one
* explicit Save. Never both: a card with an instant switch beside a Save
* button leaves a person guessing which of their changes the button covers,
* and the channel cards (Telegram, ntfy, webhook, email) did exactly that —
* the enable switch wrote on tap while the rest of the form waited for Save.
*
* The guard splits every settings source into card units (each
* `<SettingsCard>…</SettingsCard>` block; a file without one is a single
* unit) and fails a unit that both
* - saves explicitly: a `type="submit"` control or the `common.save` label,
* - saves instantly: a Switch / NativeSelect / Select / Segmented / Checkbox
* whose change handler does more than set local draft state.
*
* Its limit, written down: the handler check is textual. It follows calls to
* functions declared in the same file two levels deep; a write hidden behind
* a callback handed in through props, or behind an imported helper whose
* name carries none of the write markers, reads as a draft field.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const SETTINGS_DIR = join(ROOT, "src", "components", "settings");
const EXTRA_FILES = [
"src/components/labs/labs-settings.tsx",
"src/components/illness/illness-settings.tsx",
"src/components/measurement-reminders/vorsorge-settings.tsx",
];
/**
* `<file>#<card index>` → why the unit may mix both models. Empty on purpose;
* an entry needs a reason a reviewer can check.
*/
const ALLOWLIST: Readonly<Record<string, string>> = {};
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
out.push(...walk(full));
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
/** Each `<SettingsCard>` block with its nested cards counted. */
function splitCardUnits(source: string): string[] {
const open = /<SettingsCard\b/g;
const units: string[] = [];
let match: RegExpExecArray | null;
let consumedTo = 0;
while ((match = open.exec(source)) !== null) {
if (match.index < consumedTo) continue;
let depth = 0;
const token = /<SettingsCard\b|<\/SettingsCard>/g;
token.lastIndex = match.index;
let t: RegExpExecArray | null;
let end = source.length;
while ((t = token.exec(source)) !== null) {
depth += t[0] === "</SettingsCard>" ? -1 : 1;
if (depth === 0) {
end = t.index + t[0].length;
break;
}
}
units.push(source.slice(match.index, end));
consumedTo = end;
}
return units.length > 0 ? units : [source];
}
/** The JSX opening tag starting at `start`, braces and strings respected. */
function openingTag(source: string, start: number): string {
let depth = 0;
let quote: string | null = null;
for (let i = start; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === quote && source[i - 1] !== "\\") quote = null;
continue;
}
if (depth > 0 && (c === '"' || c === "'" || c === "`")) {
quote = c;
continue;
}
if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) return source.slice(start, i + 1);
}
return source.slice(start);
}
/** The `{…}` expression of `attr=` inside a tag, or null. */
function attributeExpression(tag: string, attr: string): string | null {
const at = tag.search(new RegExp(`\\b${attr}=\\{`));
if (at < 0) return null;
const open = tag.indexOf("{", at);
let depth = 0;
for (let i = open; i < tag.length; i++) {
if (tag[i] === "{") depth++;
else if (tag[i] === "}") {
depth--;
if (depth === 0) return tag.slice(open + 1, i).trim();
}
}
return null;
}
/** A call that writes to the server (or the locale) on its own. */
const WRITE_MARKER =
/\.mutate(?:Async)?\(|\bapi(?:FetchRaw|Fetch|Get|Put|Patch|Post|Delete)\(|\bsafeFetch\(|\bpersist\w*\(/;
/** The body of a function or arrow const declared in `source`, or null. */
function localDefinition(source: string, name: string): string | null {
const decl = new RegExp(
`(?:function\\s+${name}\\s*\\(|(?:const|let)\\s+${name}\\s*=)`,
).exec(source);
if (!decl) return null;
// A braceless arrow (`const f = (v) => g(v);`) ends at its line.
const rest = source.slice(decl.index);
const arrow = /^[^\n{]*=>\s*(?!\{)([^\n]*)/.exec(rest);
if (arrow) return arrow[0];
const open = source.indexOf("{", decl.index);
if (open < 0) return null;
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return source.slice(decl.index, i + 1);
}
}
return null;
}
/**
* True when a change handler writes on change: the handler itself carries a
* write call, or a function it calls that is declared in the same file does
* (followed two levels deep). A handler that only touches local state, or
* calls a callback handed in through props, reads as a draft field.
*/
function writesOnChange(handler: string, source: string, depth = 0): boolean {
if (WRITE_MARKER.test(handler)) return true;
if (depth >= 2) return false;
const names = new Set<string>();
if (/^[A-Za-z_]\w*$/.test(handler)) names.add(handler);
for (const m of handler.matchAll(/\b([A-Za-z_]\w*)\s*\(/g)) {
names.add(m[1]);
}
for (const name of names) {
const body = localDefinition(source, name);
if (body && writesOnChange(body, source, depth + 1)) return true;
}
return false;
}
const CONTROL = /<(Switch|NativeSelect|Select|Segmented|Checkbox)\b/g;
const HANDLERS = ["onCheckedChange", "onChange", "onValueChange"];
function savesInstantly(unit: string, source: string = unit): boolean {
CONTROL.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CONTROL.exec(unit)) !== null) {
const tag = openingTag(unit, match.index);
for (const attr of HANDLERS) {
const handler = attributeExpression(tag, attr);
if (handler !== null && writesOnChange(handler, source)) return true;
}
}
return false;
}
function savesExplicitly(unit: string): boolean {
return /type="submit"/.test(unit) || /t\("common\.save"\)/.test(unit);
}
function scan() {
const files = [
...walk(SETTINGS_DIR),
...EXTRA_FILES.map((f) => join(ROOT, f)),
];
const mixed: string[] = [];
let units = 0;
for (const file of files) {
const rel = relative(ROOT, file);
const source = readFileSync(file, "utf8");
splitCardUnits(source).forEach((unit, index) => {
units++;
if (savesInstantly(unit, source) && savesExplicitly(unit)) {
const key = `${rel}#${index}`;
if (!ALLOWLIST[key]) mixed.push(key);
}
});
}
return { mixed, units, files: files.length };
}
describe("settings save model", () => {
it("classifies handlers", () => {
const source = `
function handleToggle(next) { mutation.mutate(next); }
function toggleItem(kind, on) { setDraft({ kind, on }); }
const flip = (v) => handleToggle(v);
`;
expect(writesOnChange("setChatId", source)).toBe(false);
expect(writesOnChange("(e) => setUrl(e.target.value)", source)).toBe(false);
expect(writesOnChange("(v) => toggleItem('a', v)", source)).toBe(false);
expect(
writesOnChange("(checked) => toggleEnabled.mutate(checked)", source),
).toBe(true);
expect(writesOnChange("handleToggle", source)).toBe(true);
expect(writesOnChange("(v) => flip(v)", source)).toBe(true);
});
it("refuses a card that both saves on change and behind a Save button", () => {
const mixedCard = `<SettingsCard><Switch onCheckedChange={(v) => save.mutate(v)} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
const draftCard = `<SettingsCard><Switch onCheckedChange={setEnabled} /><Button type="submit">{t("common.save")}</Button></SettingsCard>`;
expect(
savesInstantly(splitCardUnits(mixedCard)[0]) &&
savesExplicitly(splitCardUnits(mixedCard)[0]),
).toBe(true);
expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false);
});
it("finds no card that mixes instant and explicit saving", () => {
const { mixed, units, files } = scan();
// An empty scan would pass for the wrong reason.
expect(files).toBeGreaterThan(50);
expect(units).toBeGreaterThan(100);
expect(
mixed,
`cards mixing both save models:\n${mixed.join("\n")}`,
).toEqual([]);
});
it("keeps every allowlist entry pointing at a unit that still mixes", () => {
for (const key of Object.keys(ALLOWLIST)) {
const [file, index] = key.split("#");
const source = readFileSync(join(ROOT, file), "utf8");
const unit = splitCardUnits(source)[Number(index)];
expect(unit, key).toBeDefined();
expect(savesInstantly(unit, source) && savesExplicitly(unit), key).toBe(
true,
);
}
});
});
@@ -26,10 +26,14 @@ function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
/**
* Every confirm the source spells: an `<AlertDialogAction …>…</AlertDialogAction>`
* block, or a `<ConfirmButton … />` whose `confirmLabel` is the action label.
*/
function actions(source: string): string[] {
return [
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
...source.matchAll(/<ConfirmButton\b[\s\S]*?\/>/g),
].map((m) => m[0]);
}
@@ -41,7 +45,9 @@ describe("token revoke labels", () => {
);
expect(revokes.length).toBeGreaterThan(0);
for (const action of revokes) {
expect(action).toContain('t("settings.tokenRevokeAction")');
expect(action).toMatch(
/(confirmLabel=\{|>\s*\{)t\("settings\.tokenRevokeAction"\)/,
);
}
});
+56 -114
View File
@@ -4,17 +4,7 @@ import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -137,62 +127,38 @@ function DataResetCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => setConfirmOpen(true)}
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
// Hold the dialog open while the request runs so the pending
// state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.dangerZoneConfirm")}
body={t("settings.dangerZoneConfirmDescription")}
confirmLabel={t("settings.finalDelete")}
onConfirm={() => void handleDeleteAllData()}
pending={deleting}
slot="settings-data-reset"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -291,63 +257,39 @@ function AccountDeleteCard() {
</p>
)}
<SettingsCardActions>
<AlertDialog
<Button
type="button"
onClick={() => setConfirmOpen(true)}
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
<ConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
// Hold the dialog open while the request runs so the pending
// state stays on screen and a stray dismissal cannot fire it twice.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
title={t("settings.deleteAccountConfirmTitle")}
body={t("settings.deleteAccountConfirmDescription")}
confirmLabel={t("settings.deleteAccountFinal")}
onConfirm={() => void handleDeleteAccount()}
pending={deleting}
slot="settings-account-delete"
holdOpen
destructive
/>
</SettingsCardActions>
</SettingsCard>
);
@@ -18,18 +18,8 @@ import {
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -246,30 +236,17 @@ export function FallbackChainCard({
</p>
)}
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="min-h-11 sm:min-h-9">
<RotateCcw className="h-4 w-4" />
{t("settings.ai.providerChain.resetDefaults")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.providerChain.resetConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.providerChain.resetConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={resetToDefaults}>
{t("settings.ai.providerChain.resetDefaults")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.providerChain.resetDefaults")}
title={t("settings.ai.providerChain.resetConfirmTitle")}
body={t("settings.ai.providerChain.resetConfirmBody")}
confirmLabel={t("settings.ai.providerChain.resetDefaults")}
onConfirm={() => void resetToDefaults()}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-4 w-4" />}
destructive
/>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
+25 -77
View File
@@ -17,19 +17,9 @@ import {
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
@@ -591,39 +581,19 @@ function ApiTokensCard() {
: t("settings.tokenNeverUsed")}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-9 w-9"
aria-label={t("settings.tokenRevokeAction")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-9 w-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</td>
</tr>
);
@@ -690,39 +660,17 @@ function ApiTokensCard() {
? formatDateTime(tok.lastUsedAt)
: t("settings.tokenNeverUsed")}
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevokeAction")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.tokenRevoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.tokenRevokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevokeAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.tokenRevokeAction")}
title={t("settings.tokenRevoke")}
body={t("settings.tokenRevokeDescription")}
confirmLabel={t("settings.tokenRevokeAction")}
onConfirm={() => handleRevoke(tok.id)}
size="sm"
className="min-h-11 w-full"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
);
})}
+35 -11
View File
@@ -7,16 +7,29 @@
* note left over from an earlier save would say the opposite of the truth.
* A secret field (header value, auth token, bot token) is write-only: the
* server never returns it, so any typed value counts as a change.
*
* The enable switch is a field of the same form: it saves with the one Save
* button like every other field, so a flipped switch is a change too.
*/
function enabledChanged(
form: { enabled: boolean },
saved: { enabled?: boolean },
): boolean {
return form.enabled !== (saved.enabled ?? false);
}
export function isWebhookFormDirty(
form: {
url: string;
headerName: string;
headerValue: string;
format: "generic" | "gotify";
enabled: boolean;
},
saved: { url: string; headerName: string; format?: string } | undefined,
saved:
| { url: string; headerName: string; format?: string; enabled?: boolean }
| undefined,
): boolean {
if (!saved) return false;
const savedFormat = saved.format === "gotify" ? "gotify" : "generic";
@@ -24,34 +37,45 @@ export function isWebhookFormDirty(
form.url !== saved.url ||
form.headerName !== saved.headerName ||
form.headerValue !== "" ||
form.format !== savedFormat
form.format !== savedFormat ||
enabledChanged(form, saved)
);
}
export function isNtfyFormDirty(
form: { serverUrl: string; topic: string; authToken: string },
saved: { serverUrl: string; topic: string } | undefined,
form: {
serverUrl: string;
topic: string;
authToken: string;
enabled: boolean;
},
saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return (
form.serverUrl !== saved.serverUrl ||
form.topic !== saved.topic ||
form.authToken !== ""
form.authToken !== "" ||
enabledChanged(form, saved)
);
}
export function isEmailFormDirty(
form: { recipient: string },
saved: { recipient: string } | undefined,
form: { recipient: string; enabled: boolean },
saved: { recipient: string; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return form.recipient !== saved.recipient;
return form.recipient !== saved.recipient || enabledChanged(form, saved);
}
export function isTelegramFormDirty(
form: { botToken: string; chatId: string },
saved: { chatId?: string | null } | undefined,
form: { botToken: string; chatId: string; enabled: boolean },
saved: { chatId?: string | null; enabled?: boolean } | undefined,
): boolean {
if (!saved) return false;
return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? "");
return (
form.botToken.trim() !== "" ||
form.chatId !== (saved.chatId ?? "") ||
enabledChanged(form, saved)
);
}
@@ -34,17 +34,6 @@ import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Brain, Loader2, Pencil, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -375,54 +364,20 @@ export function CoachMemorySection({
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.ai.coachMemory.forgetAll")}
title={t("settings.ai.coachMemory.forgetAllConfirmTitle")}
body={t("settings.ai.coachMemory.forgetAllConfirmBody")}
confirmLabel={t("settings.ai.coachMemory.forgetAllConfirmAction")}
onConfirm={() => forgetAll.mutate()}
pending={forgetAll.isPending}
disabled={!isAuthenticated}
size="sm"
className="min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
testId="settings-coach-memory-forget-all"
destructive
/>
) : null}
</SettingsCardActions>
</SettingsCard>
@@ -765,6 +765,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && (
<>
+8 -49
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -39,30 +42,6 @@ export function DateFormatSelect({
const [optimistic, setOptimistic] = useState<DateFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO";
const mutation = useMutation({
@@ -84,31 +63,19 @@ export function DateFormatSelect({
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeDateFormat(next);
setMsg(t("settings.dateFormat.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dateFormat.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.dateFormat.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dateFormat.saveError"), { cause: err });
},
});
function handleSelect(next: DateFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -128,16 +95,8 @@ export function DateFormatSelect({
</option>
))}
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dateFormat.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dateFormat.description")}
</p>
</div>
);
+23 -60
View File
@@ -13,11 +13,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -33,10 +30,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [recipient, setRecipient] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsEmail(),
@@ -47,16 +43,19 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
});
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings ? settings.recipient : null;
const settingsKey = settings
? `${settings.recipient}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey !== null && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setRecipient(settings!.recipient);
setEnabled(settings!.enabled);
}
const dirty = isEmailFormDirty({ recipient }, settings);
const dirty = isEmailFormDirty({ recipient, enabled }, settings);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/email", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -69,45 +68,12 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
EmailSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/email", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsEmail();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<EmailSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsEmail(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsEmail() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -128,16 +94,16 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
id="email-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -156,12 +122,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -173,7 +136,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
+10 -51
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -42,30 +45,6 @@ export function GlucoseUnitSelect({
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<GlucoseUnit | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: GlucoseUnit =
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
@@ -85,36 +64,24 @@ export function GlucoseUnitSelect({
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.glucoseUnit.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dashboard.glucoseUnit.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.glucoseUnit.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dashboard.glucoseUnit.saveError"), {
cause: err,
});
},
});
function handleSelect(next: GlucoseUnit) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -134,16 +101,8 @@ export function GlucoseUnitSelect({
<option value="mg/dL">mg/dL</option>
<option value="mmol/L">mmol/L</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.glucoseUnit.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dashboard.glucoseUnit.description")}
</p>
</div>
);
@@ -1,44 +0,0 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { apiFetchRaw } from "@/lib/api/api-fetch";
interface EnabledSettings {
enabled: boolean;
}
export async function persistChannelEnabled(
endpoint: string,
enabled: boolean,
fallbackError: string,
): Promise<void> {
const response = await apiFetchRaw(endpoint, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
const json = (await response.json()) as { error?: string };
throw new Error(json.error || fallbackError);
}
}
export function optimisticallySetChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
enabled: boolean,
): T | undefined {
const previous = queryClient.getQueryData<T>(queryKey);
queryClient.setQueryData<T>(queryKey, (current) =>
current ? { ...current, enabled } : current,
);
return previous;
}
export function rollbackChannelEnabled<T extends EnabledSettings>(
queryClient: QueryClient,
queryKey: QueryKey,
previous: T | undefined,
): void {
queryClient.setQueryData(queryKey, previous);
}
+24 -60
View File
@@ -14,11 +14,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -37,10 +34,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
const [topic, setTopic] = useState("");
const [authToken, setAuthToken] = useState("");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsNtfy(),
@@ -52,18 +48,22 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.serverUrl}|${settings.topic}`
? `${settings.serverUrl}|${settings.topic}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
setServerUrl(settings!.serverUrl);
setTopic(settings!.topic);
setEnabled(settings!.enabled);
}
const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings);
const dirty = isNtfyFormDirty(
{ serverUrl, topic, authToken, enabled },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/ntfy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -93,46 +93,13 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setAuthToken("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
NtfySettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled("/api/settings/ntfy", enabled, t("common.error")),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsNtfy();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<NtfySettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(queryClient, queryKeys.settingsNtfy(), previous);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsNtfy() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -149,16 +116,16 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
id="ntfy-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -199,12 +166,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -216,7 +180,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
@@ -27,6 +27,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -311,46 +312,21 @@ export function PasskeyListSection({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(pk.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.deletePasskey")}
title={t("settings.deletePasskey")}
body={t("settings.deletePasskeyDescription")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(pk.id)}
pending={remove.isPending && remove.variables === pk.id}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -13,18 +13,8 @@ import {
X,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -308,46 +298,23 @@ export function SecurityKeysCard({
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
disabled={remove.isPending}
aria-label={t("settings.security.keys.remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.security.keys.remove")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.security.keys.removeConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={remove.isPending}
aria-busy={remove.isPending || undefined}
onClick={() => remove.mutate(key.id)}
>
{remove.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label=""
ariaLabel={t("settings.security.keys.remove")}
title={t("settings.security.keys.remove")}
body={t("settings.security.keys.removeConfirm")}
confirmLabel={t("common.delete")}
onConfirm={() => remove.mutate(key.id)}
pending={
remove.isPending && remove.variables === key.id
}
disabled={remove.isPending}
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 sm:h-8 sm:min-h-0 sm:w-8 sm:min-w-0"
icon={<Trash2 className="h-4 w-4" />}
destructive
/>
</div>
)}
</div>
@@ -498,6 +498,7 @@ export function TotpCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
data-testid="recovery-regenerate-confirm"
disabled={regenerate.isPending}
aria-busy={regenerate.isPending || undefined}
+16 -43
View File
@@ -26,19 +26,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -255,38 +245,21 @@ function ShareLinksCard() {
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.sharing.revoke")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.sharing.revokeDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmButton
label={t("settings.sharing.revoke")}
title={t("settings.sharing.revoke")}
body={t("settings.sharing.revokeDescription")}
confirmLabel={t("settings.sharing.revoke")}
onConfirm={() => revokeMutation.mutate(link.id)}
pending={
revokeMutation.isPending &&
revokeMutation.variables === link.id
}
size="sm"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
icon={<Trash2 className="h-3.5 w-3.5" />}
destructive
/>
</li>
))}
</ul>
@@ -697,6 +697,7 @@ export function SourcesSection() {
disabled={saveMutation.isPending}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
destructive
/>
{dirty && priority ? (
<>
+26 -79
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -39,9 +36,10 @@ export function TelegramCard({
const queryClient = useQueryClient();
const [botToken, setBotToken] = useState("");
const [chatId, setChatId] = useState("");
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [error, setError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.telegramSettings(),
@@ -52,67 +50,25 @@ export function TelegramCard({
});
// Sync from server using the React-recommended "previous-payload-id"
// pattern instead of setState-in-effect. This avoids the strict
// `react-hooks/set-state-in-effect` lint failure that the legacy file
// disabled at module scope.
const settingsKey = settings ? (settings.chatId ?? "") : null;
// pattern instead of setState-in-effect. Reseeds after every save, so the
// switch reads what the server stored.
const settingsKey = settings
? `${settings.chatId ?? ""}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
setSeededKey(settingsKey);
if (settings!.chatId) setChatId(settings!.chatId);
setEnabled(settings!.enabled);
}
const dirty = isTelegramFormDirty({ botToken, chatId }, settings);
const toggleEnabled = useMutation<
void,
Error,
boolean,
TelegramSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/telegram",
enabled,
t("settings.savingError"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.telegramSettings();
await queryClient.cancelQueries({ queryKey });
setMsg(null);
setMsgType(null);
return optimisticallySetChannelEnabled<TelegramSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setMsg(t("settings.saved"));
setMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.telegramSettings(),
previous,
);
setMsg(err.message);
setMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.telegramSettings() });
},
});
const dirty = isTelegramFormDirty({ botToken, chatId, enabled }, settings);
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
setError(null);
const body: Record<string, unknown> = {
enabled: settings?.enabled ?? false,
};
const body: Record<string, unknown> = { enabled };
if (botToken.trim()) body.botToken = botToken.trim();
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
@@ -126,16 +82,16 @@ export function TelegramCard({
}).catch(() => null);
if (res?.ok) {
setMsg(t("settings.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.saved"));
setBotToken("");
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
} else {
const json = (await res?.json().catch(() => null)) as {
error?: string;
} | null;
setMsg(json?.error || t("settings.savingError"));
setMsgType("error");
const message = json?.error || t("settings.savingError");
setError(message);
showErrorToast(message);
}
} finally {
setSaving(false);
@@ -192,9 +148,9 @@ export function TelegramCard({
<div className="flex items-center gap-3">
<Switch
id="tg-enabled"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={saving || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={saving}
/>
<Label htmlFor="tg-enabled" className="cursor-pointer">
{t("settings.enableNotifications")}
@@ -209,14 +165,9 @@ export function TelegramCard({
</ol>
</SettingsInfoTile>
{msg && (msgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{msg}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
@@ -226,11 +177,7 @@ export function TelegramCard({
disabled={!settings?.hasBotToken}
unsavedChanges={dirty}
/>
<Button
type="submit"
disabled={saving || toggleEnabled.isPending}
className="min-h-11"
>
<Button type="submit" disabled={saving} className="min-h-11">
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
@@ -179,6 +179,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
destructive
/>
</SettingsCardActions>
) : null}
@@ -371,6 +372,7 @@ function MetricRow({
setOverrideMode(false);
onReset();
}}
destructive
/>
{hasOverride && (
<Badge variant="outline" className="text-xs">
@@ -445,6 +447,7 @@ function MetricRow({
confirmLabel={t("thresholds.resetConfirm")}
pending={busy}
onConfirm={onReset}
destructive
/>
)}
</div>
+8 -49
View File
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -39,30 +42,6 @@ export function TimeFormatSelect({
const [optimistic, setOptimistic] = useState<TimeFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: TimeFormatPreference = optimistic ?? user?.timeFormat ?? "AUTO";
const mutation = useMutation({
@@ -84,31 +63,19 @@ export function TimeFormatSelect({
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeTimeFormat(next);
setMsg(t("settings.timeFormat.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.timeFormat.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.timeFormat.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.timeFormat.saveError"), { cause: err });
},
});
function handleSelect(next: TimeFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -126,16 +93,8 @@ export function TimeFormatSelect({
<option value="H24">{t("settings.timeFormat.h24")}</option>
<option value="H12">{t("settings.timeFormat.h12")}</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.timeFormat.description")}
<p className="text-muted-foreground text-xs">
{t("settings.timeFormat.description")}
</p>
</div>
);
@@ -1,8 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
@@ -36,30 +39,6 @@ export function UnitPreferenceSelect({
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<UnitPreference | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: UnitPreference = optimistic ?? user?.unitPreference ?? "metric";
const mutation = useMutation({
@@ -78,36 +57,22 @@ export function UnitPreferenceSelect({
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.units.saved"));
setMsgType("success");
toastWrittenOutcome("success", t("settings.dashboard.units.saved"));
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userUnitPreference(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.units.saveError"),
);
setMsgType("error");
scheduleClear();
showErrorToast(t("settings.dashboard.units.saveError"), { cause: err });
},
});
function handleSelect(next: UnitPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -126,16 +91,8 @@ export function UnitPreferenceSelect({
{t("settings.dashboard.units.imperial")}
</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.units.description")}
<p className="text-muted-foreground text-xs">
{t("settings.dashboard.units.description")}
</p>
</div>
);
+21 -68
View File
@@ -15,11 +15,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
import {
optimisticallySetChannelEnabled,
persistChannelEnabled,
rollbackChannelEnabled,
} from "@/components/settings/notification-channel-toggle";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -41,10 +38,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [headerName, setHeaderName] = useState("");
const [headerValue, setHeaderValue] = useState("");
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
null,
);
// The enable switch is a draft field: it saves with the form's one Save.
const [enabled, setEnabled] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: settings } = useQuery({
queryKey: queryKeys.settingsWebhook(),
@@ -56,7 +52,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// React-recommended sync-from-server pattern (no setState-in-effect).
const settingsKey = settings
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}|${settings.enabled}`
: null;
const [seededKey, setSeededKey] = useState<string | null>(null);
if (settingsKey && settingsKey !== seededKey) {
@@ -64,14 +60,15 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setUrl(settings!.url);
setHeaderName(settings!.headerName);
setFormat(settings!.format === "gotify" ? "gotify" : "generic");
setEnabled(settings!.enabled);
}
const dirty = isWebhookFormDirty(
{ url, headerName, headerValue, format },
{ url, headerName, headerValue, format, enabled },
settings,
);
const save = useMutation({
mutationFn: async (enabled: boolean) => {
mutationFn: async () => {
const res = await apiFetchRaw("/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -102,54 +99,13 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
setSaveError(null);
toastWrittenOutcome("success", t("settings.saved"));
setHeaderValue("");
},
onError: (err: Error) => {
setSaveMsg(err.message);
setSaveMsgType("error");
},
});
const toggleEnabled = useMutation<
void,
Error,
boolean,
WebhookSettings | undefined
>({
mutationFn: (enabled) =>
persistChannelEnabled(
"/api/settings/webhook",
enabled,
t("common.error"),
),
onMutate: async (enabled) => {
const queryKey = queryKeys.settingsWebhook();
await queryClient.cancelQueries({ queryKey });
setSaveMsg(null);
setSaveMsgType(null);
return optimisticallySetChannelEnabled<WebhookSettings>(
queryClient,
queryKey,
enabled,
);
},
onSuccess: () => {
setSaveMsg(t("settings.saved"));
setSaveMsgType("success");
},
onError: (err, _enabled, previous) => {
rollbackChannelEnabled(
queryClient,
queryKeys.settingsWebhook(),
previous,
);
setSaveMsg(err.message);
setSaveMsgType("error");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
setSaveError(err.message);
showErrorToast(err.message, { cause: err });
},
});
@@ -166,16 +122,16 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch
id="webhook-toggle"
checked={settings?.enabled ?? false}
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
disabled={save.isPending || toggleEnabled.isPending}
checked={enabled}
onCheckedChange={setEnabled}
disabled={save.isPending}
/>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate(settings?.enabled ?? false);
save.mutate();
}}
className="space-y-4"
>
@@ -244,12 +200,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</div>
</div>
{saveMsg && (saveMsgType === "error" || !dirty) && (
<p
role="alert"
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{saveMsg}
{saveError && (
<p role="alert" className="text-destructive text-sm">
{saveError}
</p>
)}
@@ -261,7 +214,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
/>
<Button
type="submit"
disabled={save.isPending || toggleEnabled.isPending}
disabled={save.isPending}
className="min-h-11"
>
{save.isPending && (
@@ -241,3 +241,35 @@ describe("<ConfirmDialog> — the trigger-less half", () => {
expect(carriers.map(nameOf)).toEqual(["ConfirmBody"]);
});
});
describe("ConfirmDialog with holdOpen — the request stays on screen", () => {
const onConfirm = vi.fn();
const body = walk(
ConfirmDialog({
open: true,
onOpenChange: () => {},
onConfirm,
pending: true,
holdOpen: true,
...COPY,
}) as ReactElement,
).find((el) => nameOf(el) === "ConfirmBody")!;
const rendered = walk(
(body.type as (p: unknown) => ReactElement)(body.props),
);
it("keeps the dialog open on confirm and still calls back", () => {
const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
const preventDefault = vi.fn();
(action.props as { onClick: (e: unknown) => void }).onClick({
preventDefault,
});
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("holds Cancel while the request runs", () => {
const cancel = rendered.find((el) => nameOf(el) === "AlertDialogCancel")!;
expect((cancel.props as { disabled?: boolean }).disabled).toBe(true);
});
});
+35 -3
View File
@@ -52,6 +52,12 @@ interface ConfirmCopy {
pending?: boolean;
/** `data-slot` for tests and the design guards. */
slot?: string;
/**
* `data-testid` for the trigger; the dialog's action carries
* `<testId>-confirm`. For call sites whose tests already address the
* control by test id.
*/
testId?: string;
/** A control that belongs with the question, such as an opt-out checkbox. */
extra?: ReactNode;
/**
@@ -59,6 +65,16 @@ interface ConfirmCopy {
* data-list selection bar's confirm already does.
*/
destructive?: boolean;
/**
* The confirm keeps the dialog open: the caller closes it once the act
* settled, and Cancel waits while `pending`. For an act whose in-flight
* state must stay on screen (an irreversible delete), where a dismissal
* mid-request would read as cancelled. Only meaningful on `ConfirmDialog`,
* whose caller owns `open`.
*/
holdOpen?: boolean;
/** The confirm stays disabled, e.g. until a field in `extra` is valid. */
confirmDisabled?: boolean;
}
function ConfirmBody({
@@ -68,8 +84,11 @@ function ConfirmBody({
onConfirm,
pending = false,
slot,
testId,
extra,
destructive = false,
holdOpen = false,
confirmDisabled = false,
}: ConfirmCopy) {
const { t } = useTranslations();
@@ -81,13 +100,23 @@ function ConfirmBody({
</AlertDialogHeader>
{extra}
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogCancel disabled={holdOpen && pending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant={destructive ? "destructive" : "default"}
data-slot={slot ? `${slot}-confirm` : undefined}
disabled={pending}
data-testid={testId ? `${testId}-confirm` : undefined}
disabled={pending || confirmDisabled}
aria-busy={pending || undefined}
onClick={onConfirm}
onClick={
holdOpen
? (event) => {
event.preventDefault();
onConfirm();
}
: onConfirm
}
>
{pending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
@@ -113,6 +142,7 @@ export function ConfirmButton({
icon,
ariaLabel,
slot,
testId,
extra,
destructive = false,
}: ConfirmCopy & {
@@ -136,6 +166,7 @@ export function ConfirmButton({
className={className}
aria-label={ariaLabel}
data-slot={slot}
data-testid={testId}
disabled={disabled || pending}
>
{pending ? (
@@ -156,6 +187,7 @@ export function ConfirmButton({
onConfirm={onConfirm}
pending={pending}
slot={slot}
testId={testId}
extra={extra}
destructive={destructive}
/>