mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
7986e59147
commit
4aa8b1cad7
10 files changed
+158
-15
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user