mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0 * test: stabilize water capture re-entry
This commit is contained in:
1 parent
b01822be48
commit
62eb431d66
372 files changed
+25178
-8561
No files matched your search
@@ -0,0 +1,157 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
mutationOptions: [] as Array<{
|
||||
mutationFn: (enabled: boolean) => Promise<void>;
|
||||
onMutate?: (enabled: boolean) => Promise<unknown>;
|
||||
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
|
||||
}>,
|
||||
apiFetchRaw: vi.fn(),
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
invalidateQueries: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly string[] }) => {
|
||||
const key = queryKey.join("/");
|
||||
if (key === "settings/webhook") {
|
||||
return {
|
||||
data: {
|
||||
enabled: false,
|
||||
url: "https://saved.example/hook",
|
||||
headerName: "Authorization",
|
||||
hasHeaderValue: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (key === "settings/ntfy") {
|
||||
return {
|
||||
data: {
|
||||
enabled: false,
|
||||
serverUrl: "https://ntfy.sh",
|
||||
topic: "saved-topic",
|
||||
hasAuthToken: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (key === "settings/email") {
|
||||
return {
|
||||
data: {
|
||||
enabled: false,
|
||||
recipient: "saved@example.com",
|
||||
smtpConfigured: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: { enabled: false, hasBotToken: true, chatId: "saved-chat" },
|
||||
};
|
||||
},
|
||||
useMutation: (options: {
|
||||
mutationFn: (enabled: boolean) => Promise<void>;
|
||||
onMutate?: (enabled: boolean) => Promise<unknown>;
|
||||
onError?: (error: Error, enabled: boolean, previous: unknown) => void;
|
||||
}) => {
|
||||
mocks.mutationOptions.push(options);
|
||||
return { mutate: vi.fn(), isPending: false };
|
||||
},
|
||||
useQueryClient: () => mocks.queryClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: mocks.apiFetchRaw,
|
||||
apiGet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../test-connection-button", () => ({
|
||||
TestConnectionButton: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WebhookCard } from "../webhook-card";
|
||||
import { NtfyCard } from "../ntfy-card";
|
||||
import { EmailCard } from "../email-card";
|
||||
import { TelegramCard } from "../telegram-card";
|
||||
|
||||
function render(card: ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{card}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.mutationOptions.length = 0;
|
||||
mocks.queryClient.cancelQueries.mockResolvedValue(undefined);
|
||||
mocks.apiFetchRaw.mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: { saved: true }, error: null }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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],
|
||||
] 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SeededFormDiscardDialog,
|
||||
useSeededFormDismissal,
|
||||
} from "@/components/forms/use-seeded-form-dismissal";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -43,6 +47,10 @@ export function AccountSection() {
|
||||
// settled (React #418 family).
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const navigate = useCallback(
|
||||
(target: string) => router.push(target),
|
||||
[router],
|
||||
);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [heightCm, setHeightCm] = useState("");
|
||||
@@ -53,6 +61,16 @@ export function AccountSection() {
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [insurerName, setInsurerName] = useState("");
|
||||
const [insuranceNumber, setInsuranceNumber] = useState("");
|
||||
const [profileSeed, setProfileSeed] = useState({
|
||||
email: "",
|
||||
heightCm: "",
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
timezone: "Europe/Berlin",
|
||||
fullName: "",
|
||||
insurerName: "",
|
||||
insuranceNumber: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<StatusMessage | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
@@ -88,26 +106,62 @@ export function AccountSection() {
|
||||
// satisfying the strict `react-hooks/set-state-in-effect` rule.
|
||||
const [seededUserId, setSeededUserId] = useState<string | null>(null);
|
||||
if (user && user.id !== seededUserId) {
|
||||
setSeededUserId(user.id);
|
||||
setEmail(user.email ?? "");
|
||||
setHeightCm(user.heightCm?.toString() ?? "");
|
||||
setDateOfBirth(
|
||||
user.dateOfBirth
|
||||
const seed = {
|
||||
email: user.email ?? "",
|
||||
heightCm: user.heightCm?.toString() ?? "",
|
||||
dateOfBirth: user.dateOfBirth
|
||||
? new Date(user.dateOfBirth).toISOString().slice(0, 10)
|
||||
: "",
|
||||
);
|
||||
setGender(user.gender ?? "");
|
||||
setTimezone(resolveInitialTimezone(user.timezone, detectBrowserTimezone()));
|
||||
setFullName(user.fullName ?? "");
|
||||
setInsurerName(user.insurerName ?? "");
|
||||
setInsuranceNumber(user.insuranceNumber ?? "");
|
||||
gender: user.gender ?? "",
|
||||
timezone: resolveInitialTimezone(user.timezone, detectBrowserTimezone()),
|
||||
fullName: user.fullName ?? "",
|
||||
insurerName: user.insurerName ?? "",
|
||||
insuranceNumber: user.insuranceNumber ?? "",
|
||||
};
|
||||
setSeededUserId(user.id);
|
||||
setEmail(seed.email);
|
||||
setHeightCm(seed.heightCm);
|
||||
setDateOfBirth(seed.dateOfBirth);
|
||||
setGender(seed.gender);
|
||||
setTimezone(seed.timezone);
|
||||
setFullName(seed.fullName);
|
||||
setInsurerName(seed.insurerName);
|
||||
setInsuranceNumber(seed.insuranceNumber);
|
||||
setProfileSeed(seed);
|
||||
}
|
||||
|
||||
const profileDismissal = useSeededFormDismissal({
|
||||
seed: profileSeed,
|
||||
value: {
|
||||
email,
|
||||
heightCm,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName,
|
||||
insurerName,
|
||||
insuranceNumber,
|
||||
},
|
||||
blocked: saving,
|
||||
guardNavigation: true,
|
||||
navigate,
|
||||
});
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
const savedProfile = {
|
||||
email: email.trim(),
|
||||
heightCm,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName: fullName.trim(),
|
||||
insurerName: insurerName.trim(),
|
||||
insuranceNumber: insuranceNumber.trim(),
|
||||
};
|
||||
|
||||
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
|
||||
// resolver cache can be invalidated without piping the flag
|
||||
@@ -118,13 +172,15 @@ export function AccountSection() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: email.trim() || null,
|
||||
heightCm: heightCm ? parseFloat(heightCm) : null,
|
||||
dateOfBirth: dateOfBirth || null,
|
||||
gender: gender || null,
|
||||
fullName: fullName.trim() || null,
|
||||
insurerName: insurerName.trim() || null,
|
||||
insuranceNumber: insuranceNumber.trim() || null,
|
||||
email: savedProfile.email || null,
|
||||
heightCm: savedProfile.heightCm
|
||||
? parseFloat(savedProfile.heightCm)
|
||||
: null,
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
insurerName: savedProfile.insurerName || null,
|
||||
insuranceNumber: savedProfile.insuranceNumber || null,
|
||||
}),
|
||||
}),
|
||||
user && timezone && timezone !== user.timezone
|
||||
@@ -146,6 +202,11 @@ export function AccountSection() {
|
||||
}
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
setEmail(savedProfile.email);
|
||||
setFullName(savedProfile.fullName);
|
||||
setInsurerName(savedProfile.insurerName);
|
||||
setInsuranceNumber(savedProfile.insuranceNumber);
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
} else if (!tzRes.ok) {
|
||||
// The dedicated tz endpoint owns the IANA validation error
|
||||
@@ -579,6 +640,11 @@ export function AccountSection() {
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
<SeededFormDiscardDialog
|
||||
open={profileDismissal.discardDialogOpen}
|
||||
onConfirm={profileDismissal.confirmDiscard}
|
||||
onCancel={profileDismissal.cancelDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Anthropic form — API key + model dropdown.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
@@ -37,6 +37,7 @@ export function AnthropicProviderForm({
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
@@ -85,10 +86,25 @@ export function AnthropicProviderForm({
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
onSettled: () => {
|
||||
submitInFlightRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitInFlightRef.current || saveMutation.isPending) return;
|
||||
submitInFlightRef.current = true;
|
||||
saveMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="ai-provider-config-anthropic" className="space-y-4">
|
||||
<form
|
||||
data-testid="ai-provider-config-anthropic"
|
||||
className="space-y-4"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
{t("settings.ai.anthropicKeyLabel")}
|
||||
@@ -148,9 +164,10 @@ export function AnthropicProviderForm({
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
aria-busy={saveMutation.isPending || undefined}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
@@ -167,6 +184,6 @@ export function AnthropicProviderForm({
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Local (OpenAI-compatible) form — base URL + optional key + model.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
@@ -38,6 +38,7 @@ export function LocalProviderForm({
|
||||
const [customModel, setCustomModel] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
@@ -85,10 +86,25 @@ export function LocalProviderForm({
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
onSettled: () => {
|
||||
submitInFlightRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitInFlightRef.current || saveMutation.isPending) return;
|
||||
submitInFlightRef.current = true;
|
||||
saveMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="ai-provider-config-local" className="space-y-4">
|
||||
<form
|
||||
data-testid="ai-provider-config-local"
|
||||
className="space-y-4"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
{/* v1.28.28 (#470) — name the gateway path explicitly. The Local
|
||||
provider is the ONE user-level custom-URL provider, so it is also
|
||||
the documented way to reach LiteLLM / OpenRouter / vLLM and any
|
||||
@@ -156,9 +172,10 @@ export function LocalProviderForm({
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
aria-busy={saveMutation.isPending || undefined}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
@@ -175,6 +192,6 @@ export function LocalProviderForm({
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* pick is visible to every code path that reads the row.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
@@ -42,6 +42,7 @@ export function OpenAIProviderForm({
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
const seededKey =
|
||||
userProvider != null
|
||||
@@ -92,10 +93,25 @@ export function OpenAIProviderForm({
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
onSettled: () => {
|
||||
submitInFlightRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitInFlightRef.current || saveMutation.isPending) return;
|
||||
submitInFlightRef.current = true;
|
||||
saveMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="ai-provider-config-openai" className="space-y-4">
|
||||
<form
|
||||
data-testid="ai-provider-config-openai"
|
||||
className="space-y-4"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="ai-openai-key">
|
||||
{t("settings.ai.openai.apiKey")}
|
||||
@@ -185,9 +201,10 @@ export function OpenAIProviderForm({
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
aria-busy={saveMutation.isPending || undefined}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
@@ -204,6 +221,6 @@ export function OpenAIProviderForm({
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* (~120 s, `AI_BUDGETS.comprehensive.timeoutMs`).
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ResponseTimeoutCard({
|
||||
const [value, setValue] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState(false);
|
||||
const submitInFlightRef = useRef(false);
|
||||
|
||||
// Seed from the persisted value once it arrives (seed-on-data pattern).
|
||||
const seededKey =
|
||||
@@ -74,10 +75,24 @@ export function ResponseTimeoutCard({
|
||||
setOk(false);
|
||||
setMsg(e instanceof Error ? e.message : t("settings.ai.errorGeneric"));
|
||||
},
|
||||
onSettled: () => {
|
||||
submitInFlightRef.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (submitInFlightRef.current || saveMutation.isPending) return;
|
||||
submitInFlightRef.current = true;
|
||||
saveMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 space-y-3 rounded-lg p-4">
|
||||
<form
|
||||
className="bg-muted/50 space-y-3 rounded-lg p-4"
|
||||
onSubmit={submit}
|
||||
noValidate
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.ai.responseTimeoutHeading")}
|
||||
@@ -104,9 +119,10 @@ export function ResponseTimeoutCard({
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
aria-busy={saveMutation.isPending || undefined}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
@@ -122,6 +138,6 @@ export function ResponseTimeoutCard({
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -202,6 +202,7 @@ export function RuntimeActionsRow({
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
|
||||
@@ -11,6 +11,11 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} 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";
|
||||
@@ -70,6 +75,39 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
});
|
||||
|
||||
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() });
|
||||
},
|
||||
});
|
||||
|
||||
// Operator hasn't configured SMTP — no transport, so no card. The dispatcher
|
||||
// skips the channel too; surfacing an unusable toggle would only confuse.
|
||||
if (settings && !settings.smtpConfigured) return null;
|
||||
@@ -88,8 +126,8 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Switch
|
||||
id="email-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => save.mutate(checked)}
|
||||
disabled={save.isPending}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +169,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -56,7 +56,7 @@ type ExportFormat = "CSV" | "JSON" | "FHIR";
|
||||
* one place.
|
||||
*/
|
||||
async function downloadFromUrl(url: string, filename: string): Promise<void> {
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
const res = await apiFetchRaw(url, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Download failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import de from "../../../../../messages/de.json";
|
||||
import en from "../../../../../messages/en.json";
|
||||
import es from "../../../../../messages/es.json";
|
||||
import fr from "../../../../../messages/fr.json";
|
||||
import itMessages from "../../../../../messages/it.json";
|
||||
import pl from "../../../../../messages/pl.json";
|
||||
|
||||
let statusPayload: { lastSyncedAt: string | null } | undefined;
|
||||
let statusLoading = false;
|
||||
let statusError = false;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: statusPayload,
|
||||
isLoading: statusLoading,
|
||||
isError: statusError,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AppleHealthCard } from "../apple-health-card";
|
||||
|
||||
function render(locale: Locale = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AppleHealthCard enabled />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<AppleHealthCard>", () => {
|
||||
beforeEach(() => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = false;
|
||||
statusError = false;
|
||||
});
|
||||
|
||||
it("explains that live sync uses the iOS app rather than OAuth", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-card"');
|
||||
expect(html).toContain("HealthLog iOS app");
|
||||
expect(html).toContain("not a web or OAuth connection");
|
||||
expect(html).toContain("Settings → Apple Health");
|
||||
expect(html).toContain("when iOS grants background time");
|
||||
expect(html).not.toContain(">Connected<");
|
||||
});
|
||||
|
||||
it("keeps the status neutral when no Apple Health data has been observed", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-status"');
|
||||
expect(html).toContain('data-state="setup"');
|
||||
expect(html).toContain("Set up in iOS app");
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("reports only observed data freshness without claiming a connection", () => {
|
||||
statusPayload = { lastSyncedAt: "2026-07-20T10:30:00.000Z" };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="recent-data"');
|
||||
expect(html).toContain("Apple Health data received");
|
||||
expect(html).toContain("Last Apple Health data:");
|
||||
expect(html).toContain('dateTime="2026-07-20T10:30:00.000Z"');
|
||||
expect(html).not.toContain(">Connected<");
|
||||
expect(html).not.toContain('data-state="connected"');
|
||||
});
|
||||
|
||||
it("links to the existing one-shot Apple Health import fallback", () => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-testid="apple-health-import-link"');
|
||||
expect(html).toContain(
|
||||
'href="/settings/export#settings-section-import-title"',
|
||||
);
|
||||
expect(html).toContain("Open one-shot import");
|
||||
});
|
||||
|
||||
it("uses a non-committal checking state while status is loading", () => {
|
||||
statusPayload = undefined;
|
||||
statusLoading = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="checking"');
|
||||
expect(html).toContain("Checking Apple Health data…");
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
it("does not turn a failed status read into a setup or connection claim", () => {
|
||||
statusError = true;
|
||||
|
||||
const html = render();
|
||||
|
||||
expect(html).toContain('data-state="unavailable"');
|
||||
expect(html).toContain("Apple Health status unavailable");
|
||||
expect(html).not.toContain('data-state="setup"');
|
||||
expect(html).not.toContain("Connected");
|
||||
});
|
||||
|
||||
it("defines every card key in all six locale catalogs", () => {
|
||||
const requiredKeys = [
|
||||
"title",
|
||||
"description",
|
||||
"lastDataLabel",
|
||||
"setupTitle",
|
||||
"permissionStep",
|
||||
"backgroundStep",
|
||||
"importNote",
|
||||
"importAction",
|
||||
] as const;
|
||||
|
||||
for (const catalog of [en, de, es, fr, itMessages, pl]) {
|
||||
const appleHealth = catalog.settings.appleHealth;
|
||||
for (const key of requiredKeys) {
|
||||
expect(appleHealth[key]).toBeTypeOf("string");
|
||||
expect(appleHealth[key].length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(Object.keys(appleHealth.status).sort()).toEqual([
|
||||
"checking",
|
||||
"dataReceived",
|
||||
"setup",
|
||||
"unavailable",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["en", "de", "es", "fr", "it", "pl"] as const)(
|
||||
"renders localized Apple Health copy for %s without leaking keys",
|
||||
(locale) => {
|
||||
statusPayload = { lastSyncedAt: null };
|
||||
statusLoading = false;
|
||||
|
||||
const html = render(locale);
|
||||
|
||||
expect(html).toContain("Apple Health");
|
||||
expect(html).not.toContain("settings.appleHealth.");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, HeartPulse, Info, Upload } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface HealthKitStatus {
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const {
|
||||
data: status,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.healthKitStatus(),
|
||||
queryFn: () => apiGet<HealthKitStatus>("/api/integrations/healthkit"),
|
||||
enabled,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const lastSyncedAt = status?.lastSyncedAt ?? null;
|
||||
const statusState = isLoading
|
||||
? "checking"
|
||||
: isError
|
||||
? "unavailable"
|
||||
: lastSyncedAt
|
||||
? "recent-data"
|
||||
: "setup";
|
||||
const statusLabel = isLoading
|
||||
? t("settings.appleHealth.status.checking")
|
||||
: isError
|
||||
? t("settings.appleHealth.status.unavailable")
|
||||
: lastSyncedAt
|
||||
? t("settings.appleHealth.status.dataReceived")
|
||||
: t("settings.appleHealth.status.setup");
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="apple-health-card-title"
|
||||
data-testid="apple-health-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
titleId="apple-health-card-title"
|
||||
title={t("settings.appleHealth.title")}
|
||||
description={t("settings.appleHealth.description")}
|
||||
status={
|
||||
<Badge
|
||||
variant={lastSyncedAt ? undefined : "outline"}
|
||||
data-testid="apple-health-status"
|
||||
data-state={statusState}
|
||||
className={
|
||||
lastSyncedAt
|
||||
? "border-success/30 bg-success/15 text-success max-w-full whitespace-nowrap"
|
||||
: "max-w-full whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
{lastSyncedAt ? (
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Info className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
<span className="truncate">{statusLabel}</span>
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{lastSyncedAt && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="apple-health-last-data"
|
||||
>
|
||||
{t("settings.appleHealth.lastDataLabel")}{" "}
|
||||
<time dateTime={lastSyncedAt}>{format.dateTime(lastSyncedAt)}</time>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.appleHealth.setupTitle")}
|
||||
</h3>
|
||||
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
|
||||
<li>{t("settings.appleHealth.permissionStep")}</li>
|
||||
<li>{t("settings.appleHealth.backgroundStep")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="border-border bg-muted/30 flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-xs sm:max-w-xl">
|
||||
{t("settings.appleHealth.importNote")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/export#settings-section-import-title"
|
||||
data-testid="apple-health-import-link"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("settings.appleHealth.importAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
|
||||
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
|
||||
@@ -285,6 +286,9 @@ export function ConnectionsPanel() {
|
||||
<div id="google-health" className="scroll-mt-28">
|
||||
<GoogleHealthCard viewModel={googleHealthViewModel} />
|
||||
</div>
|
||||
<div id="apple-health" className="scroll-mt-28">
|
||||
<AppleHealthCard enabled={isAuthenticated} />
|
||||
</div>
|
||||
<div id="polar" className="scroll-mt-28">
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
type ChannelType = "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS";
|
||||
|
||||
@@ -97,7 +97,9 @@ export function NotificationStatusCard() {
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: async (channel: ChannelStatus) => {
|
||||
const res = await fetch(TEST_ENDPOINTS[channel.type], { method: "POST" });
|
||||
const res = await apiFetchRaw(TEST_ENDPOINTS[channel.type], {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error((json as { error?: string }).error ?? "test_failed");
|
||||
|
||||
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} 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";
|
||||
@@ -82,6 +87,39 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
});
|
||||
|
||||
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() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
@@ -96,8 +134,8 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Switch
|
||||
id="ntfy-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => save.mutate(checked)}
|
||||
disabled={save.isPending}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -161,7 +199,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MessageCircle, Save } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} 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";
|
||||
@@ -31,7 +36,6 @@ export function TelegramCard({
|
||||
const queryClient = useQueryClient();
|
||||
const [botToken, setBotToken] = useState("");
|
||||
const [chatId, setChatId] = useState("");
|
||||
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);
|
||||
@@ -48,23 +52,63 @@ export function TelegramCard({
|
||||
// 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.enabled}|${settings.chatId ?? ""}`
|
||||
: null;
|
||||
const settingsKey = settings ? (settings.chatId ?? "") : null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setEnabled(settings!.enabled);
|
||||
if (settings!.chatId) setChatId(settings!.chatId);
|
||||
}
|
||||
|
||||
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() });
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(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;
|
||||
|
||||
@@ -137,8 +181,9 @@ export function TelegramCard({
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="tg-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={saving || toggleEnabled.isPending}
|
||||
/>
|
||||
<Label htmlFor="tg-enabled" className="cursor-pointer">
|
||||
{t("settings.enableNotifications")}
|
||||
@@ -167,7 +212,11 @@ export function TelegramCard({
|
||||
endpoint="/api/settings/telegram/test"
|
||||
disabled={!settings?.hasBotToken}
|
||||
/>
|
||||
<Button type="submit" disabled={saving} className="min-h-11">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || toggleEnabled.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Shared "Test connection" UX for the settings integrations + notifications
|
||||
@@ -46,7 +47,7 @@ export function TestConnectionButton({
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await fetch(endpoint, { method: "POST" });
|
||||
const res = await apiFetchRaw(endpoint, { method: "POST" });
|
||||
const json = (await res.json().catch(() => ({}))) as TestResponse;
|
||||
|
||||
if (res.ok && json.data?.ok !== false) {
|
||||
|
||||
@@ -103,7 +103,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
const url = metric
|
||||
? `/api/user/thresholds?metric=${metric}`
|
||||
: "/api/user/thresholds";
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
const res = await apiFetchRaw(url, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -12,6 +12,11 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import {
|
||||
optimisticallySetChannelEnabled,
|
||||
persistChannelEnabled,
|
||||
rollbackChannelEnabled,
|
||||
} 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";
|
||||
@@ -82,6 +87,47 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
},
|
||||
});
|
||||
|
||||
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() });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
@@ -96,8 +142,8 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
<Switch
|
||||
id="webhook-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => save.mutate(checked)}
|
||||
disabled={save.isPending}
|
||||
onCheckedChange={(checked) => toggleEnabled.mutate(checked)}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +211,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={save.isPending}
|
||||
disabled={save.isPending || toggleEnabled.isPending}
|
||||
className="min-h-11"
|
||||
>
|
||||
{save.isPending && (
|
||||
|
||||
Reference in new issue
Block a user