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:
Marc-André Bombeck committed 2026-10-21 21:58:22 +02:00
1 parent 709d28523c
commit 9cfc72fe1f
11 files changed
+413 -189

No files matched your search

+18 -2
View File
@@ -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);
}
});
});
+11 -35
View File
@@ -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 ?? "");
}
+17 -14
View File
@@ -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 });
},
});
}
+13 -14
View File
@@ -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 && (
+31 -27
View File
@@ -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" />
) : (
+14 -12
View File
@@ -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 && (