mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
93198017d6
commit
3ebef19ac3
32 files changed
+830
-1135
No files matched your search
@@ -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"\)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user