fix(medications): keep injection-site dialog open until the PATCH settles

The injection-site capture dialog dismissed optimistically the instant
Confirm fired — before the PATCH that records the site resolved, with no
pending state. A failed request left the dose looking site-tagged when it
was not, and reused the unrelated "no sites available" string for the
error. The dialog now holds a submitting state: Confirm and Skip disable,
a spinner shows, and a backdrop tap or Esc cannot dismiss while the
request is in flight. The card handlers keep the dialog mounted until the
PATCH succeeds and surface a dedicated save-failed toast on failure.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 18:37:23 +02:00
1 parent 7986e59147
commit 4aa8b1cad7
10 files changed
+158 -15

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -0,0 +1,101 @@
/**
* v1.11.5 — `<LogInjectionSiteDialog>` 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 }) => (
<div>{children}</div>
);
return {
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-slot="mock-dialog">{children}</div> : 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const src = readFileSync(
resolve(__dirname, "../log-injection-site-dialog.tsx"),
"utf8",
);
describe("<LogInjectionSiteDialog>", () => {
it("renders the skip / confirm footer with Confirm gated on a selection", () => {
const html = render(
<LogInjectionSiteDialog
open
medicationName="Mounjaro"
allowedInjectionSites={[]}
globalExcludedInjectionSites={[]}
history={[]}
onConfirm={vi.fn()}
onSkip={vi.fn()}
/>,
);
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<void>",
);
});
});
@@ -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;
@@ -2,6 +2,8 @@
import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -38,8 +40,13 @@ interface LogInjectionSiteDialogProps {
globalExcludedInjectionSites: ReadonlyArray<InjectionSiteKey>;
/** Recent rotation history (most recent first) for the dashed-ring hint. */
history: ReadonlyArray<InjectionSiteKey>;
/** 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<void>;
/** 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<InjectionSiteKey | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
// 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({
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={onSkip}>
<Button variant="outline" onClick={onSkip} disabled={submitting}>
{t("medications.logInjectionSiteSkip")}
</Button>
<Button
disabled={selected === null}
disabled={selected === null || submitting}
aria-busy={submitting || undefined}
onClick={() => {
if (selected !== null) onConfirm(selected);
void handleConfirm();
}}
>
{submitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.logInjectionSiteConfirm")}
</Button>
</DialogFooter>
@@ -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