mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(settings): channel switches act on tap again, beside their form
The Telegram, ntfy, webhook and email cards had folded the enable switch into the form, so turning a channel off took a Save as well. The switch saves on tap again, optimistically with a rollback, and sits outside the form; the form's Save covers the channel's fields and sends the stored enable state unchanged. The save-model guard now splits a card into save units, each form and the rest of the card, and refuses a unit that mixes both models: an instant switch inside the form its Save submits, or a Save outside any form next to one, still fails. A form shows its refusal inline only, no longer as a toast on top.
This commit is contained in:
1 parent
709d28523c
commit
9cfc72fe1f
11 files changed
+413
-189
No files matched your search
@@ -270,6 +270,21 @@ 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.
|
||||
@@ -277,10 +292,11 @@ async function saveCard(
|
||||
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",
|
||||
|
||||
@@ -21,7 +21,6 @@ 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", () => {
|
||||
@@ -57,12 +56,7 @@ 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: "",
|
||||
enabled: false,
|
||||
};
|
||||
const form = { serverUrl: "https://ntfy.sh", topic: "t", authToken: "" };
|
||||
expect(isNtfyFormDirty(form, saved)).toBe(false);
|
||||
expect(isNtfyFormDirty({ ...form, topic: "u" }, saved)).toBe(true);
|
||||
expect(isNtfyFormDirty({ ...form, serverUrl: "https://x" }, saved)).toBe(
|
||||
@@ -73,86 +67,39 @@ describe("channel form dirty state", () => {
|
||||
|
||||
it("email: the recipient counts", () => {
|
||||
expect(
|
||||
isEmailFormDirty(
|
||||
{ recipient: "a@x.io", enabled: false },
|
||||
{ recipient: "a@x.io" },
|
||||
),
|
||||
isEmailFormDirty({ recipient: "a@x.io" }, { recipient: "a@x.io" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isEmailFormDirty(
|
||||
{ recipient: "b@x.io", enabled: false },
|
||||
{ recipient: "a@x.io" },
|
||||
),
|
||||
isEmailFormDirty({ recipient: "b@x.io" }, { 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", enabled: false },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
isTelegramFormDirty({ botToken: "", chatId: "1" }, { chatId: "1" }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty({ botToken: " ", chatId: "" }, { chatId: null }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: " ", chatId: "", enabled: false },
|
||||
{ chatId: null },
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "123:abc", chatId: "1", enabled: false },
|
||||
{ botToken: "123:abc", chatId: "1" },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "", chatId: "2", enabled: false },
|
||||
{ chatId: "1" },
|
||||
),
|
||||
isTelegramFormDirty({ botToken: "", chatId: "2" }, { 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", enabled: true },
|
||||
undefined,
|
||||
),
|
||||
isNtfyFormDirty({ serverUrl: "", topic: "", authToken: "x" }, undefined),
|
||||
).toBe(false);
|
||||
expect(isEmailFormDirty({ recipient: "x", enabled: true }, undefined)).toBe(
|
||||
expect(isEmailFormDirty({ recipient: "x" }, undefined)).toBe(false);
|
||||
expect(isTelegramFormDirty({ botToken: "x", chatId: "" }, undefined)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isTelegramFormDirty(
|
||||
{ botToken: "x", chatId: "", enabled: true },
|
||||
undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -126,22 +126,56 @@ describe("notification channel cards", () => {
|
||||
["email", <EmailCard key="email" isAuthenticated />],
|
||||
["telegram", <TelegramCard key="telegram" isAuthenticated />],
|
||||
] as const)(
|
||||
"%s enable switch is a form field: it writes nothing on its own",
|
||||
async (_channel, card) => {
|
||||
"%s enable switch saves on tap, alone, outside the form",
|
||||
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"');
|
||||
// The switch precedes the form: its Save covers the fields, not the
|
||||
// switch.
|
||||
expect(html.indexOf('role="switch"')).toBeGreaterThan(-1);
|
||||
expect(html.indexOf('role="switch"')).toBeLessThan(html.indexOf("<form"));
|
||||
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);
|
||||
}
|
||||
|
||||
// The card's first mutation is the switch's, and it sends `enabled`
|
||||
// and nothing else.
|
||||
await mocks.mutationOptions[0]!.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 />, ["settings", "webhook"]],
|
||||
[<NtfyCard key="ntfy" isAuthenticated />, ["settings", "ntfy"]],
|
||||
[<EmailCard key="email" isAuthenticated />, ["settings", "email"]],
|
||||
[<TelegramCard key="telegram" isAuthenticated />, ["telegram", "settings"]],
|
||||
] as const)(
|
||||
"rolls each switch back to its previous server state on error",
|
||||
async (card, queryKey) => {
|
||||
const previous = { enabled: false, persistedConfig: "unchanged" };
|
||||
mocks.queryClient.getQueryData.mockReturnValue(previous);
|
||||
render(card);
|
||||
const toggle = mocks.mutationOptions[0]!;
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,7 +187,7 @@ describe("notification channel cards", () => {
|
||||
expect(html).toContain('<option value="gotify">');
|
||||
|
||||
// The full save is the mutation of the last render, whose closure holds
|
||||
// the values seeded from the server, the enable switch included.
|
||||
// the values seeded from the server; it carries the stored enable state.
|
||||
const save = mocks.mutationOptions.at(-1)!;
|
||||
await save.mutationFn(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,20 @@
|
||||
*
|
||||
* 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
|
||||
* unit), and each card into save units: every `<form>…</form>` inside it is
|
||||
* one, and the rest of the card is one more. It fails a save 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.
|
||||
*
|
||||
* So a card may carry an instant switch beside a form with its own Save, as
|
||||
* long as the switch is outside that form: the channel cards (Telegram, ntfy,
|
||||
* webhook, email) switch delivery on and off on tap, and their Save covers
|
||||
* the address, topic or token fields inside the form. What stays refused is
|
||||
* an instant control inside the form its Save submits, or a Save outside any
|
||||
* form next to an instant control, where nothing marks which change the
|
||||
* button covers.
|
||||
*
|
||||
* 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
|
||||
@@ -179,6 +188,45 @@ function savesInstantly(unit: string, source: string = unit): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card's save units: each `<form>` block inside it, then whatever of the
|
||||
* card lies outside every form.
|
||||
*/
|
||||
function splitSaveUnits(card: string): string[] {
|
||||
const forms: string[] = [];
|
||||
let rest = "";
|
||||
let cursor = 0;
|
||||
const open = /<form\b/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = open.exec(card)) !== null) {
|
||||
if (match.index < cursor) continue;
|
||||
let depth = 0;
|
||||
const token = /<form\b|<\/form>/g;
|
||||
token.lastIndex = match.index;
|
||||
let t: RegExpExecArray | null;
|
||||
let end = card.length;
|
||||
while ((t = token.exec(card)) !== null) {
|
||||
depth += t[0] === "</form>" ? -1 : 1;
|
||||
if (depth === 0) {
|
||||
end = t.index + t[0].length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
rest += card.slice(cursor, match.index);
|
||||
forms.push(card.slice(match.index, end));
|
||||
cursor = end;
|
||||
}
|
||||
rest += card.slice(cursor);
|
||||
return [...forms, rest];
|
||||
}
|
||||
|
||||
/** Does any save unit of this card mix both models? */
|
||||
function mixesSaveModels(card: string, source: string = card): boolean {
|
||||
return splitSaveUnits(card).some(
|
||||
(unit) => savesInstantly(unit, source) && savesExplicitly(unit),
|
||||
);
|
||||
}
|
||||
|
||||
function savesExplicitly(unit: string): boolean {
|
||||
return /type="submit"/.test(unit) || /t\("common\.save"\)/.test(unit);
|
||||
}
|
||||
@@ -195,7 +243,7 @@ function scan() {
|
||||
const source = readFileSync(file, "utf8");
|
||||
splitCardUnits(source).forEach((unit, index) => {
|
||||
units++;
|
||||
if (savesInstantly(unit, source) && savesExplicitly(unit)) {
|
||||
if (mixesSaveModels(unit, source)) {
|
||||
const key = `${rel}#${index}`;
|
||||
if (!ALLOWLIST[key]) mixed.push(key);
|
||||
}
|
||||
@@ -231,6 +279,15 @@ describe("settings save model", () => {
|
||||
expect(savesInstantly(splitCardUnits(draftCard)[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an instant switch beside a form with its own Save, nothing looser", () => {
|
||||
const split = `<SettingsCard><Switch onCheckedChange={(v) => toggle.mutate(v)} /><form onSubmit={save}><Input /><Button type="submit">{t("common.save")}</Button></form></SettingsCard>`;
|
||||
const inside = `<SettingsCard><form onSubmit={save}><Switch onCheckedChange={(v) => toggle.mutate(v)} /><Button type="submit">{t("common.save")}</Button></form></SettingsCard>`;
|
||||
const formless = `<SettingsCard><Switch onCheckedChange={(v) => toggle.mutate(v)} /><Button onClick={save}>{t("common.save")}</Button></SettingsCard>`;
|
||||
expect(mixesSaveModels(split)).toBe(false);
|
||||
expect(mixesSaveModels(inside)).toBe(true);
|
||||
expect(mixesSaveModels(formless)).toBe(true);
|
||||
});
|
||||
|
||||
it("finds no card that mixes instant and explicit saving", () => {
|
||||
const { mixed, units, files } = scan();
|
||||
// An empty scan would pass for the wrong reason.
|
||||
@@ -248,9 +305,7 @@ describe("settings save model", () => {
|
||||
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,
|
||||
);
|
||||
expect(mixesSaveModels(unit, source), key).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,29 +7,16 @@
|
||||
* 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; enabled?: boolean }
|
||||
| undefined,
|
||||
saved: { url: string; headerName: string; format?: string } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
const savedFormat = saved.format === "gotify" ? "gotify" : "generic";
|
||||
@@ -37,45 +24,34 @@ export function isWebhookFormDirty(
|
||||
form.url !== saved.url ||
|
||||
form.headerName !== saved.headerName ||
|
||||
form.headerValue !== "" ||
|
||||
form.format !== savedFormat ||
|
||||
enabledChanged(form, saved)
|
||||
form.format !== savedFormat
|
||||
);
|
||||
}
|
||||
|
||||
export function isNtfyFormDirty(
|
||||
form: {
|
||||
serverUrl: string;
|
||||
topic: string;
|
||||
authToken: string;
|
||||
enabled: boolean;
|
||||
},
|
||||
saved: { serverUrl: string; topic: string; enabled?: boolean } | undefined,
|
||||
form: { serverUrl: string; topic: string; authToken: string },
|
||||
saved: { serverUrl: string; topic: string } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return (
|
||||
form.serverUrl !== saved.serverUrl ||
|
||||
form.topic !== saved.topic ||
|
||||
form.authToken !== "" ||
|
||||
enabledChanged(form, saved)
|
||||
form.authToken !== ""
|
||||
);
|
||||
}
|
||||
|
||||
export function isEmailFormDirty(
|
||||
form: { recipient: string; enabled: boolean },
|
||||
saved: { recipient: string; enabled?: boolean } | undefined,
|
||||
form: { recipient: string },
|
||||
saved: { recipient: string } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return form.recipient !== saved.recipient || enabledChanged(form, saved);
|
||||
return form.recipient !== saved.recipient;
|
||||
}
|
||||
|
||||
export function isTelegramFormDirty(
|
||||
form: { botToken: string; chatId: string; enabled: boolean },
|
||||
saved: { chatId?: string | null; enabled?: boolean } | undefined,
|
||||
form: { botToken: string; chatId: string },
|
||||
saved: { chatId?: string | null } | undefined,
|
||||
): boolean {
|
||||
if (!saved) return false;
|
||||
return (
|
||||
form.botToken.trim() !== "" ||
|
||||
form.chatId !== (saved.chatId ?? "") ||
|
||||
enabledChanged(form, saved)
|
||||
);
|
||||
return form.botToken.trim() !== "" || form.chatId !== (saved.chatId ?? "");
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isEmailFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -30,8 +30,6 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [recipient, setRecipient] = useState("");
|
||||
// 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({
|
||||
@@ -43,23 +41,28 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.recipient}|${settings.enabled}`
|
||||
: null;
|
||||
const settingsKey = settings ? settings.recipient : 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, enabled }, settings);
|
||||
const dirty = isEmailFormDirty({ recipient }, settings);
|
||||
// The enable switch saves on tap; the form's Save covers the recipient.
|
||||
const toggle = useChannelEnabledToggle<EmailSettings>(
|
||||
"/api/settings/email",
|
||||
queryKeys.settingsEmail(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/settings/email", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recipient, enabled }),
|
||||
body: JSON.stringify({
|
||||
recipient,
|
||||
enabled: settings?.enabled ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
@@ -71,9 +74,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
setSaveError(null);
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
// A form shows its refusal inline, next to the field it is about.
|
||||
onError: (err: Error) => {
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,9 +97,9 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
|
||||
<Switch
|
||||
id="email-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggle.mutate(checked)}
|
||||
disabled={!settings || save.isPending || toggle.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +139,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggle.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The instant enable switch of a notification channel card (Telegram, ntfy,
|
||||
* webhook, email).
|
||||
*
|
||||
* The switch saves on tap, optimistically, and rolls back when the server
|
||||
* refuses. It sits outside the card's form: the form's Save covers the
|
||||
* channel's fields (address, topic, token), the switch only whether the
|
||||
* channel delivers. The save-model guard holds that split.
|
||||
*/
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface EnabledSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export async function persistChannelEnabled(
|
||||
endpoint: string,
|
||||
enabled: boolean,
|
||||
fallbackError: string,
|
||||
): Promise<void> {
|
||||
// A request that never reaches the server (offline, a dropped connection)
|
||||
// is a refused change like any other.
|
||||
const response = await apiFetchRaw(endpoint, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
const json = (await response?.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mutation behind a channel's enable switch. A toggle is no form, so its
|
||||
* outcome is a toast either way; the switch itself already shows the state.
|
||||
*/
|
||||
export function useChannelEnabledToggle<T extends EnabledSettings>(
|
||||
endpoint: string,
|
||||
queryKey: QueryKey,
|
||||
) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, boolean, T | undefined>({
|
||||
mutationFn: (enabled) =>
|
||||
persistChannelEnabled(endpoint, enabled, t("settings.savingError")),
|
||||
onMutate: async (enabled) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
return optimisticallySetChannelEnabled<T>(queryClient, queryKey, enabled);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
onError: (err, _enabled, previous) => {
|
||||
rollbackChannelEnabled(queryClient, queryKey, previous);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isNtfyFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -34,8 +34,6 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [serverUrl, setServerUrl] = useState("https://ntfy.sh");
|
||||
const [topic, setTopic] = useState("");
|
||||
const [authToken, setAuthToken] = useState("");
|
||||
// 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({
|
||||
@@ -48,18 +46,19 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings
|
||||
? `${settings.serverUrl}|${settings.topic}|${settings.enabled}`
|
||||
? `${settings.serverUrl}|${settings.topic}`
|
||||
: 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, enabled },
|
||||
settings,
|
||||
const dirty = isNtfyFormDirty({ serverUrl, topic, authToken }, settings);
|
||||
// The enable switch saves on tap; the form's Save covers the fields below.
|
||||
const toggle = useChannelEnabledToggle<NtfySettings>(
|
||||
"/api/settings/ntfy",
|
||||
queryKeys.settingsNtfy(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
@@ -71,7 +70,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
serverUrl,
|
||||
topic,
|
||||
authToken: authToken || undefined,
|
||||
enabled,
|
||||
enabled: settings?.enabled ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -97,9 +96,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
setAuthToken("");
|
||||
},
|
||||
// A form shows its refusal inline, next to the fields it is about.
|
||||
onError: (err: Error) => {
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,9 +115,9 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
|
||||
<Switch
|
||||
id="ntfy-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggle.mutate(checked)}
|
||||
disabled={!settings || save.isPending || toggle.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +179,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggle.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isTelegramFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -36,8 +36,6 @@ 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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -50,25 +48,28 @@ export function TelegramCard({
|
||||
});
|
||||
|
||||
// Sync from server using the React-recommended "previous-payload-id"
|
||||
// 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;
|
||||
// pattern instead of setState-in-effect.
|
||||
const settingsKey = settings ? (settings.chatId ?? "") : 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, enabled }, settings);
|
||||
const dirty = isTelegramFormDirty({ botToken, chatId }, settings);
|
||||
// The enable switch saves on tap; the form's Save covers token and chat.
|
||||
const toggle = useChannelEnabledToggle<TelegramSettings>(
|
||||
"/api/settings/telegram",
|
||||
queryKeys.telegramSettings(),
|
||||
);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const body: Record<string, unknown> = { enabled };
|
||||
const body: Record<string, unknown> = {
|
||||
enabled: settings?.enabled ?? false,
|
||||
};
|
||||
if (botToken.trim()) body.botToken = botToken.trim();
|
||||
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
||||
|
||||
@@ -89,9 +90,8 @@ export function TelegramCard({
|
||||
const json = (await res?.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
const message = json?.error || t("settings.savingError");
|
||||
setError(message);
|
||||
showErrorToast(message);
|
||||
// A form shows its refusal inline, next to the fields it is about.
|
||||
setError(json?.error || t("settings.savingError"));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -107,6 +107,18 @@ export function TelegramCard({
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="tg-enabled">
|
||||
{t("settings.enableNotifications")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="tg-enabled"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggle.mutate(checked)}
|
||||
disabled={!settings || saving || toggle.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -145,18 +157,6 @@ export function TelegramCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="tg-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
||||
{t("settings.enableNotifications")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<SettingsInfoTile icon={Info} tone="neutral">
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
<li>{t("settings.telegramStep1")}</li>
|
||||
@@ -177,7 +177,11 @@ export function TelegramCard({
|
||||
disabled={!settings?.hasBotToken}
|
||||
unsavedChanges={dirty}
|
||||
/>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || toggle.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { isWebhookFormDirty } from "@/components/settings/channel-form-dirty";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useChannelEnabledToggle } from "@/components/settings/notification-channel-toggle";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -38,8 +38,6 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [format, setFormat] = useState<WebhookPayloadFormat>("generic");
|
||||
// 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({
|
||||
@@ -52,7 +50,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.enabled}`
|
||||
? `${settings.url}|${settings.headerName}|${settings.format ?? "generic"}`
|
||||
: null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
@@ -60,12 +58,16 @@ 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, enabled },
|
||||
{ url, headerName, headerValue, format },
|
||||
settings,
|
||||
);
|
||||
// The enable switch saves on tap; the form's Save covers the fields below.
|
||||
const toggle = useChannelEnabledToggle<WebhookSettings>(
|
||||
"/api/settings/webhook",
|
||||
queryKeys.settingsWebhook(),
|
||||
);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -77,7 +79,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
headerName: headerName || undefined,
|
||||
headerValue: headerValue || undefined,
|
||||
format,
|
||||
enabled,
|
||||
enabled: settings?.enabled ?? false,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -103,9 +105,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
setHeaderValue("");
|
||||
},
|
||||
// A form shows its refusal inline, next to the fields it is about.
|
||||
onError: (err: Error) => {
|
||||
setSaveError(err.message);
|
||||
showErrorToast(err.message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,9 +124,9 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
|
||||
<Switch
|
||||
id="webhook-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={save.isPending}
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggle.mutate(checked)}
|
||||
disabled={!settings || save.isPending || toggle.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -214,7 +216,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggle.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
Reference in new issue
Block a user