Files
HealthLog/src/components/settings/test-connection-button.tsx
T
Marc-André Bombeck 65e86e4a53 feat(settings): extract all six remaining sections — content lands at /settings/<slug> (#143)
Replace the SectionPlaceholder fallbacks for Account, AI, Integrations,
Notifications, Dashboard, API, Advanced, and About with real per-section
components. Every /settings/<slug> URL now renders working content; the
placeholder only fires for unknown slugs.

A2-content extraction
- Account: profile + passkeys + password change (account-section.tsx)
- AI: insights + per-user provider override (ai-section.tsx)
- Integrations: Withings + moodLog (integrations-section.tsx)
- Notifications: Telegram + ntfy + Web Push (split into telegram-card,
  ntfy-card, web-push-card to keep each file under ~600 LOC)
- Dashboard: layout + thresholds wrappers (dashboard-section.tsx)
- API: endpoints + tokens (api-section.tsx)
- Advanced: export + danger zone (advanced-section.tsx)
- About (greenfield): version, license, build SHA, repo/changelog/docs
  links + "Check for updates" against GitHub releases

A2-about
- Reads /api/version via TanStack Query, surfaces app version, AGPL-3.0
  badge, short build SHA, build timestamp, repo/changelog/docs links.
- "Check for updates" button compares against api.github.com latest
  release tag with up-to-date / newer-available / failure feedback.

A8-UI test buttons
- Shared TestConnectionButton primitive that POSTs to a test endpoint,
  surfaces ok-with-latency or translated meta.errorCode failures.
- Wired into Withings + moodLog (Integrations) and Web Push
  (Notifications). Glitchtip + Umami remain admin-only.

i18n
- New settings.about.* and settings.testConnection.* key groups in en.json
  and de.json.

Cleanup
- Delete src/app/settings/page.legacy.tsx (3230 LOC monolith).
- Convert sync-from-server patterns to the React-recommended
  prev-payload-id render-time pattern so the strict
  react-hooks/set-state-in-effect rule passes without a file-level disable.

Tests
- New src/components/settings/__tests__/sections.test.tsx with 9 SSR smoke
  tests (one per section) — total suite 514 (was 505).
2026-05-08 14:52:04 +02:00

121 lines
3.4 KiB
TypeScript

"use client";
import { useState } from "react";
import { CheckCircle2, Loader2, Plug, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
/**
* Shared "Test connection" UX for the settings integrations + notifications
* sections (A8-UI). POSTs to the given endpoint, expects a `{ data, error,
* meta }` envelope, and surfaces success (latency) or a translated
* `meta.errorCode` callout.
*
* Endpoints are user-scoped, so the button intentionally does NOT send an
* Idempotency-Key — each click probes the upstream live.
*/
export interface TestConnectionButtonProps {
endpoint: string;
/** Optional disabled flag from the parent — typically "no credentials yet". */
disabled?: boolean;
/** Label override for the button (defaults to settings.testConnection.test). */
label?: string;
}
interface TestResponse {
data?: { latencyMs?: number; ok?: boolean; sent?: number };
error?: string;
meta?: { errorCode?: string };
}
export function TestConnectionButton({
endpoint,
disabled = false,
label,
}: TestConnectionButtonProps) {
const { t } = useTranslations();
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<
| { kind: "ok"; latency: number }
| { kind: "error"; errorCode: string }
| null
>(null);
async function handleClick() {
setTesting(true);
setResult(null);
try {
const res = await fetch(endpoint, { method: "POST" });
const json = (await res.json().catch(() => ({}))) as TestResponse;
if (res.ok && json.data?.ok !== false) {
setResult({
kind: "ok",
latency: json.data?.latencyMs ?? 0,
});
} else {
setResult({
kind: "error",
errorCode: json.meta?.errorCode ?? "generic",
});
}
} catch {
setResult({ kind: "error", errorCode: "connection_failed" });
} finally {
setTesting(false);
}
}
function describeError(errorCode: string): string {
const key = `settings.testConnection.errors.${errorCode}`;
const translated = t(key);
// `t()` returns the raw key when missing — fall back to generic.
if (translated === key) {
return t("settings.testConnection.errors.generic");
}
return translated;
}
return (
<div className="space-y-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClick}
disabled={disabled || testing}
>
{testing ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plug className="mr-1 h-3.5 w-3.5" />
)}
{testing
? t("settings.testConnection.testing")
: (label ?? t("settings.testConnection.test"))}
</Button>
{result?.kind === "ok" && (
<p
role="status"
className="text-dracula-green flex items-center gap-1.5 text-xs"
>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("settings.testConnection.ok", { latency: result.latency })}
</p>
)}
{result?.kind === "error" && (
<p
role="alert"
className="text-destructive flex items-center gap-1.5 text-xs"
>
<XCircle className="h-3.5 w-3.5" />
{describeError(result.errorCode)}
</p>
)}
</div>
);
}