diff --git a/messages/de.json b/messages/de.json index 0a306575b..6279c75b8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Überspringen", "logInjectionSiteConfirm": "Stelle speichern", "logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.", + "logInjectionSiteSaveFailed": "Injektionsstelle konnte nicht gespeichert werden. Bitte versuche es erneut.", "trackInjectionSitesToggle": "Injektionsstellen erfassen", "trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.", "allowedSitesLabel": "Erlaubte Stellen", diff --git a/messages/en.json b/messages/en.json index d185f1009..7bf8cd516 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Skip", "logInjectionSiteConfirm": "Save site", "logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.", + "logInjectionSiteSaveFailed": "Could not save the injection site. Please try again.", "trackInjectionSitesToggle": "Track injection sites", "trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.", "allowedSitesLabel": "Allowed sites", diff --git a/messages/es.json b/messages/es.json index 0249166c8..8e67fd515 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Omitir", "logInjectionSiteConfirm": "Guardar sitio", "logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.", + "logInjectionSiteSaveFailed": "No se pudo guardar el sitio de inyección. Inténtalo de nuevo.", "trackInjectionSitesToggle": "Registrar sitios de inyección", "trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.", "allowedSitesLabel": "Sitios permitidos", diff --git a/messages/fr.json b/messages/fr.json index 2b5898a33..387eb3146 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Ignorer", "logInjectionSiteConfirm": "Enregistrer le site", "logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.", + "logInjectionSiteSaveFailed": "Impossible d’enregistrer le site d’injection. Veuillez réessayer.", "trackInjectionSitesToggle": "Suivre les sites d'injection", "trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.", "allowedSitesLabel": "Sites autorisés", diff --git a/messages/it.json b/messages/it.json index 02ac12673..bb59bbeba 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Salta", "logInjectionSiteConfirm": "Salva il sito", "logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.", + "logInjectionSiteSaveFailed": "Impossibile salvare il sito di iniezione. Riprova.", "trackInjectionSitesToggle": "Registra i siti di iniezione", "trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.", "allowedSitesLabel": "Siti consentiti", diff --git a/messages/pl.json b/messages/pl.json index fb5f3e33a..3cd2f31d2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1360,6 +1360,7 @@ "logInjectionSiteSkip": "Pomiń", "logInjectionSiteConfirm": "Zapisz miejsce", "logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.", + "logInjectionSiteSaveFailed": "Nie udało się zapisać miejsca iniekcji. Spróbuj ponownie.", "trackInjectionSitesToggle": "Rejestruj miejsca iniekcji", "trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.", "allowedSitesLabel": "Dozwolone miejsca", diff --git a/src/components/medications/__tests__/log-injection-site-dialog.test.tsx b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx new file mode 100644 index 000000000..2dd543e20 --- /dev/null +++ b/src/components/medications/__tests__/log-injection-site-dialog.test.tsx @@ -0,0 +1,101 @@ +/** + * v1.11.5 — `` controlled-submit coverage. + * + * The dialog used to dismiss optimistically the instant Confirm fired — + * before the parent's PATCH resolved and with no pending state. This suite + * pins the controlled-dialog contract: + * + * 1. The dialog SSRs its skip / confirm footer (Confirm disabled until a + * site is picked). + * 2. The confirm handler awaits the (possibly async) `onConfirm`, holding + * a `submitting` state, and `onOpenChange` refuses to close while a + * request is in flight. + * + * Project convention is SSR-only component tests (`renderToStaticMarkup`, + * no `@testing-library/react`) plus source-string structural assertions for + * the interactive plumbing an SSR mount can't exercise. + */ +import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// The Radix Dialog portals at runtime, so its body never materialises in +// static markup. Collapse the primitives to plain wrappers (same trick as +// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable. +vi.mock("@/components/ui/dialog", () => { + const Pass = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: Pass, + DialogDescription: Pass, + DialogFooter: Pass, + DialogHeader: Pass, + DialogTitle: Pass, + }; +}); + +const { LogInjectionSiteDialog } = await import("../log-injection-site-dialog"); + +function render(node: React.ReactNode, locale: "en" | "de" = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +const src = readFileSync( + resolve(__dirname, "../log-injection-site-dialog.tsx"), + "utf8", +); + +describe("", () => { + it("renders the skip / confirm footer with Confirm gated on a selection", () => { + const html = render( + , + ); + expect(html).toContain("Save site"); + expect(html).toContain("Skip"); + // Confirm starts disabled (no site selected yet). + expect(html).toMatch(/Save site/); + expect(html).toContain("disabled"); + }); + + it("awaits onConfirm behind a submitting guard and re-enables in finally", () => { + expect(src).toContain("const [submitting, setSubmitting] = useState(false)"); + expect(src).toMatch( + /async function handleConfirm\(\) \{[\s\S]*setSubmitting\(true\)[\s\S]*await onConfirm\(selected\)[\s\S]*finally \{[\s\S]*setSubmitting\(false\)/, + ); + }); + + it("refuses to close the dialog while a request is in flight", () => { + expect(src).toMatch( + /onOpenChange=\{\(next\) => \{[\s\S]*if \(submitting\) return;[\s\S]*if \(!next\) onSkip\(\)/, + ); + }); + + it("disables both footer buttons and shows a spinner while submitting", () => { + expect(src).toMatch(/onClick=\{onSkip\} disabled=\{submitting\}/); + expect(src).toContain("selected === null || submitting"); + expect(src).toContain("aria-busy={submitting || undefined}"); + expect(src).toContain("Loader2"); + }); + + it("accepts an async onConfirm in its prop contract", () => { + expect(src).toContain( + "onConfirm: (site: InjectionSiteKey) => void | Promise", + ); + }); +}); diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index a687d037c..277362e4f 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -280,18 +280,21 @@ export function Glp1MedicationCard({ async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; - setSiteIntakeId(null); if (!intakeId) return; + // v1.11.5 — keep the dialog open until the PATCH resolves. On failure + // surface a toast and re-throw so the dialog stays mounted with the + // chosen site instead of dismissing as though the site had been saved. const res = await fetch("/api/medications/intake", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }), }); - if (res.ok) { - await invalidateKeys(queryClient, medicationDependentKeys); - } else { - toast.error(t("medications.logInjectionSiteNoneAvailable")); + if (!res.ok) { + toast.error(t("medications.logInjectionSiteSaveFailed")); + throw new Error("injection-site PATCH failed"); } + await invalidateKeys(queryClient, medicationDependentKeys); + setSiteIntakeId(null); } const schedule = medication.schedules[0] ?? null; diff --git a/src/components/medications/log-injection-site-dialog.tsx b/src/components/medications/log-injection-site-dialog.tsx index 748b78e3f..2b8ee7753 100644 --- a/src/components/medications/log-injection-site-dialog.tsx +++ b/src/components/medications/log-injection-site-dialog.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; +import { Loader2 } from "lucide-react"; + import { Dialog, DialogContent, @@ -38,8 +40,13 @@ interface LogInjectionSiteDialogProps { globalExcludedInjectionSites: ReadonlyArray; /** Recent rotation history (most recent first) for the dashed-ring hint. */ history: ReadonlyArray; - /** Submit the chosen site (the parent PATCHes it onto the intake). */ - onConfirm: (site: InjectionSiteKey) => void; + /** + * Submit the chosen site (the parent PATCHes it onto the intake). The + * handler may be async; the dialog stays open with a pending state until + * it resolves and rejects (throws) on failure so the dialog can keep the + * user's selection rather than dismissing optimistically. + */ + onConfirm: (site: InjectionSiteKey) => void | Promise; /** Dismiss without recording a site (the dose stays taken). */ onSkip: () => void; } @@ -55,16 +62,35 @@ export function LogInjectionSiteDialog({ }: LogInjectionSiteDialogProps) { const { t } = useTranslations(); const [selected, setSelected] = useState(null); + // v1.11.5 — the dialog used to dismiss optimistically the instant the + // confirm fired, before the PATCH resolved and with no pending state. + // It now stays open and disabled until the parent's handler settles; the + // parent throws on failure so a rejected PATCH keeps the dialog (and the + // chosen site) in place. + const [submitting, setSubmitting] = useState(false); const allowed = effectiveAllowedSites( allowedInjectionSites, globalExcludedInjectionSites, ); + async function handleConfirm() { + if (selected === null || submitting) return; + setSubmitting(true); + try { + await onConfirm(selected); + } finally { + setSubmitting(false); + } + } + return ( { + // Hold the dialog while the PATCH is in flight so a backdrop tap / + // Esc can't dismiss mid-request. + if (submitting) return; if (!next) onSkip(); }} > @@ -92,15 +118,19 @@ export function LogInjectionSiteDialog({ )} - diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b3bbdb8bf..c338661c5 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -250,18 +250,21 @@ export function MedicationCard({ async function confirmInjectionSite(site: InjectionSiteKey) { const intakeId = siteIntakeId; - setSiteIntakeId(null); if (!intakeId) return; + // v1.11.5 — keep the dialog open until the PATCH resolves. On failure + // surface a toast and re-throw so the dialog stays mounted with the + // chosen site instead of dismissing as though the site had been saved. const res = await fetch("/api/medications/intake", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }), }); - if (res.ok) { - await invalidateKeys(queryClient, medicationDependentKeys); - } else { - toast.error(t("medications.logInjectionSiteNoneAvailable")); + if (!res.ok) { + toast.error(t("medications.logInjectionSiteSaveFailed")); + throw new Error("injection-site PATCH failed"); } + await invalidateKeys(queryClient, medicationDependentKeys); + setSiteIntakeId(null); } // v1.8.6 — the two compliance windows scale with the dosing cadence. When