Files
HealthLog/src/components/settings/integrations/integration-actions.tsx
T
Marc-André Bombeck 6e54f33901 refactor(i18n): ask the catalogue for a sentence in one helper
`t()` answers a missing key with the key, so "does this code have a
sentence" was a comparison written inline at every site that wanted a
code's sentence or a fallback, twenty-odd times in several spellings.
translateOrNull, translateCodeOrNull and translateCode in
src/lib/i18n/translate-code.ts now hold that probe, every site uses
them, and a guard refuses the inline comparison anywhere else.
2026-10-20 09:53:31 +02:00

211 lines
6.1 KiB
TypeScript

"use client";
/**
* The requests behind an integration card's menu, shared by every provider
* controller: sync now (optionally the whole history), test the connection,
* and the lines that report what happened. The card renders the lines in its
* body; the menu only starts the request.
*/
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2 } from "lucide-react";
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import {
runConnectionTest,
TestConnectionFailure,
type ConnectionTestResult,
} from "@/components/settings/test-connection-button";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import {
readSyncOutcome,
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import { translateCode } from "@/lib/i18n/translate-code";
export interface ProviderSync {
syncing: boolean;
result: SyncOutcomeState | null;
run: (fullSync: boolean) => void;
}
/**
* Sync now, for every provider whose sync route answers the shared written
* outcome. The tone comes off what the run wrote, never off `res.ok`.
*/
export function useStandardSync({
provider,
i18nPrefix,
acceptsFullSync,
fullSyncResult,
statusQueryKey,
}: {
/** Route segment: POST `/api/<provider>/sync`. */
provider: string;
i18nPrefix: string;
/** The route takes `{ fullSync }`; the others take no body. */
acceptsFullSync: boolean;
/** A full sync has its own success sentence (`…FullSyncResult`). */
fullSyncResult?: boolean;
statusQueryKey?: readonly unknown[];
}): ProviderSync {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
const queryClient = useQueryClient();
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
async function run(fullSync: boolean) {
setSyncing(true);
setSyncResult(null);
try {
const res = await apiFetchRaw(
`/api/${provider}/sync`,
acceptsFullSync
? {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullSync }),
}
: { method: "POST" },
);
const json = await res.json();
const result = res.ok ? readSyncOutcome(json) : null;
if (result) {
setSyncResult({
outcome: result.outcome,
message: describeSyncOutcome(
result,
fullSync && fullSyncResult
? t(`${i18nPrefix}FullSyncResult`, { count: result.imported })
: t(`${i18nPrefix}SyncResult`, { count: result.imported }),
),
});
void invalidateKeys(queryClient, measurementDependentKeys);
if (statusQueryKey) {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
}
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncResult({
outcome: "failed",
message: json?.error || t(`${i18nPrefix}SyncFailed`),
});
}
} catch {
setSyncResult({
outcome: "failed",
message: t(`${i18nPrefix}SyncFailed`),
});
} finally {
setSyncing(false);
}
}
return {
syncing,
result: syncResult,
run: (fullSync) => void run(fullSync),
};
}
/** "Test connection" from the menu; the result shows in the card body. */
export function useConnectionTest(endpoint: string) {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<ConnectionTestResult | null>(null);
async function run() {
setTesting(true);
setResult(null);
try {
setResult(await runConnectionTest(endpoint));
} finally {
setTesting(false);
}
}
return { testing, result, run: () => void run() };
}
/**
* What the last menu action reported: a running sync or test, the sync's
* written outcome, the test's answer. Renders nothing when nothing ran.
*/
export function ActionResultLines({
provider,
sync,
test,
}: {
provider: string;
sync?: Pick<ProviderSync, "syncing" | "result">;
test?: ReturnType<typeof useConnectionTest>;
}) {
const { t } = useTranslations();
function describeError(errorCode: string): string {
return translateCode(
t,
"settings.testConnection.errors",
errorCode,
"settings.testConnection.errors.generic",
);
}
const busy = sync?.syncing || test?.testing;
const syncResult = sync?.result ?? null;
if (!busy && !syncResult && !test?.result) return null;
return (
<div className="space-y-2" data-slot="integration-action-results">
{busy ? (
<p
role="status"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Loader2
aria-hidden="true"
className="size-3.5 animate-spin motion-reduce:animate-none"
/>
{sync?.syncing
? t("settings.integrationCard.syncing")
: t("settings.testConnection.testing")}
</p>
) : null}
{syncResult ? (
<WrittenOutcomeLine
outcome={syncResult.outcome}
message={syncResult.message}
testId={`${provider}-sync-result`}
/>
) : null}
{test?.result?.kind === "ok" ? (
<p
role="status"
className="text-success flex items-center gap-1.5 text-xs"
data-testid={`${provider}-test-result`}
>
<CheckCircle2 aria-hidden="true" className="size-3.5" />
{t("settings.testConnection.ok", { latency: test.result.latency })}
</p>
) : null}
{test?.result?.kind === "error" ? (
<TestConnectionFailure
message={describeError(test.result.errorCode)}
upstreamStatus={test.result.upstreamStatus}
smtpCode={test.result.smtpCode}
upstreamBody={test.result.upstreamBody}
/>
) : null}
</div>
);
}