fix(records): offer Undo after deleting a vaccination, visit or entry

Deleting a vaccination, a doctor visit or an address-book entry closed
the sheet with no way back, although all three are soft deletes with a
documented restore route. The vaccination hook had a restore mutation
nobody called; visits and the address book had none.

The two hooks gain a restore mutation, and each delete now confirms
with an Undo toast, the same pattern the illness episode uses. The Undo
runs after the sheet has closed, so it awaits the restore itself and
the hook's own onSuccess refreshes the lists, instead of relying on
callbacks a closed sheet never receives. Restoring an address-book
entry brings the entry back, not the links the delete cleared on the
visits that named it. The three new success toasts are pinned in the
success-affordance guard.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:06:02 +01:00
1 parent f761500247
commit d204b4344b
18 files changed
+259 -46

No files matched your search

@@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record<
"src/components/documents/sources/document-source-picker.tsx": {
"text-success": 1,
},
"src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 },
"src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 },
"src/components/illness/episode-menu.tsx": {
CheckCircle2: 2,
@@ -347,8 +348,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 },
"src/components/mood/mood-list.tsx": { "toast.success": 3 },
"src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 },
"src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 },
"src/components/records/allergy-form.tsx": { "toast.success": 1 },
"src/components/records/allergy-manager.tsx": { "toast.success": 1 },
"src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 },
"src/components/records/conditions-manager.tsx": { "toast.success": 1 },
"src/components/records/family-history-form.tsx": { "toast.success": 1 },
"src/components/records/family-history-manager.tsx": { "toast.success": 1 },
+21 -3
View File
@@ -33,6 +33,7 @@
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
@@ -85,7 +86,7 @@ export function EncounterSheet({
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useEncounterMutations();
const { create, update, remove, restore } = useEncounterMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once, at mount. The caller remounts this component on every open by
// varying its `key`, so a second edit never shows the first one's values and
@@ -210,8 +211,25 @@ export function EncounterSheet({
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(encounter.id, {
onSuccess: () => onOpenChange(false),
const id = encounter.id;
remove.mutate(id, {
onSuccess: () => {
onOpenChange(false);
// The delete is soft. The Undo runs after this sheet
// closed, so it awaits the restore itself rather than
// passing callbacks a closed sheet never receives.
toast.success(t("encounters.deleted"), {
action: {
label: t("common.undo"),
onClick: () =>
void restore.mutateAsync(id).catch((failure) =>
showErrorToast(t("encounters.restoreFailed"), {
cause: failure,
}),
),
},
});
},
onError: (failure) =>
showErrorToast(t("encounters.deleteFailed"), {
cause: failure,
@@ -0,0 +1,63 @@
/**
* Deleting an address-book entry is a soft delete with a restore route, and
* nothing on the web offered it: a mistaken delete could not be taken back.
* The delete now confirms with an Undo that calls the restore.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const toastSuccess = vi.hoisted(() => vi.fn());
const errorToast = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({ toast: { success: toastSuccess } }));
vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast: errorToast }));
import { deleteWithUndo } from "../practitioner-list";
type Mutations = Parameters<typeof deleteWithUndo>[0];
const t = (key: string) => key;
function mutations(restoreResult: Promise<unknown>) {
const remove = {
mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) =>
options.onSuccess?.(),
),
};
const restore = { mutateAsync: vi.fn(() => restoreResult) };
return { remove, restore, all: { remove, restore } as unknown as Mutations };
}
beforeEach(() => {
toastSuccess.mockReset();
errorToast.mockReset();
});
describe("deleteWithUndo", () => {
it("confirms the delete with an Undo that restores the entry", async () => {
const m = mutations(Promise.resolve({ id: "p1" }));
deleteWithUndo(m.all, "p1", t);
expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything());
const [message, options] = toastSuccess.mock.calls[0]!;
expect(message).toBe("practitioners.deleted");
expect(options.action.label).toBe("common.undo");
options.action.onClick();
expect(m.restore.mutateAsync).toHaveBeenCalledWith("p1");
await Promise.resolve();
expect(errorToast).not.toHaveBeenCalled();
});
it("says so when the restore fails", async () => {
const m = mutations(Promise.reject(new Error("offline")));
deleteWithUndo(m.all, "p1", t);
toastSuccess.mock.calls[0]![1].action.onClick();
await vi.waitFor(() =>
expect(errorToast).toHaveBeenCalledWith(
"practitioners.restoreFailed",
expect.anything(),
),
);
});
});
@@ -13,6 +13,7 @@
*/
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
@@ -43,6 +44,33 @@ import { VISITS_PATH } from "@/lib/encounters/visit-links";
import { useTranslations } from "@/lib/i18n/context";
import { PractitionerSheet } from "./practitioner-sheet";
/**
* Delete an entry, with an Undo: the delete is soft, and the restore brings
* the entry back.
*/
export function deleteWithUndo(
{ remove, restore }: ReturnType<typeof usePractitionerMutations>,
id: string,
t: (key: string) => string,
) {
remove.mutate(id, {
onSuccess: () =>
toast.success(t("practitioners.deleted"), {
action: {
label: t("common.undo"),
onClick: () =>
void restore.mutateAsync(id).catch((failure) =>
showErrorToast(t("practitioners.restoreFailed"), {
cause: failure,
}),
),
},
}),
onError: (failure) =>
showErrorToast(t("practitioners.deleteFailed"), { cause: failure }),
});
}
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -52,7 +80,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
const [term, setTerm] = useState("");
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, enabled);
const { remove } = usePractitionerMutations();
const mutations = usePractitionerMutations();
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
const [deleting, setDeleting] = useState<Practitioner | null>(null);
@@ -257,12 +285,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
if (!deleting) return;
const id = deleting.id;
setDeleting(null);
remove.mutate(id, {
onError: (failure) =>
showErrorToast(t("practitioners.deleteFailed"), {
cause: failure,
}),
});
deleteWithUndo(mutations, id, t);
}}
>
{t("common.delete")}
@@ -20,6 +20,7 @@
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
@@ -69,7 +70,7 @@ export function VaccinationSheet({
onCreated?: (created: Vaccination) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useVaccinationMutations();
const { create, update, remove, restore } = useVaccinationMutations();
// The list row carries no document links; the detail read does. Until it
// answers, the draft's links stay "untouched" and Save leaves them alone.
const detail = useVaccination(vaccination?.id ?? null);
@@ -198,8 +199,28 @@ export function VaccinationSheet({
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(vaccination.id, {
onSuccess: () => onOpenChange(false),
const id = vaccination.id;
remove.mutate(id, {
onSuccess: () => {
onOpenChange(false);
// The delete is soft. The Undo runs after this sheet
// closed, so it awaits the restore itself rather than
// passing callbacks a closed sheet never receives.
toast.success(t("vaccinations.deleted"), {
action: {
label: t("common.undo"),
onClick: () =>
void restore.mutateAsync(id).catch((failure) =>
showErrorToast(
t("vaccinations.restoreFailed"),
{
cause: failure,
},
),
),
},
});
},
onError: (failure) =>
showErrorToast(t("vaccinations.deleteFailed"), {
cause: failure,
+16 -2
View File
@@ -19,7 +19,13 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { apiDelete, apiGet, apiGetList, apiPatch } from "@/lib/api/api-fetch";
import {
apiDelete,
apiGet,
apiGetList,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
import {
@@ -192,5 +198,13 @@ export function useEncounterMutations() {
onSuccess: invalidate,
});
return { create, update, remove };
// The Undo of a delete. A restored visit re-anchors its reminder only
// while its date is still ahead.
const restore = useMutation({
mutationKey: queryKeys.encounterRestore(),
mutationFn: (id: string) => apiPost<Encounter>(`${BASE}/${id}/restore`, {}),
onSuccess: invalidate,
});
return { create, update, remove, restore };
}
+10 -1
View File
@@ -91,5 +91,14 @@ export function usePractitionerMutations() {
onSuccess: invalidate,
});
return { create, update, remove };
// The Undo of a delete. It brings the entry back, not the links the
// delete cleared on the visits that named it.
const restore = useMutation({
mutationKey: queryKeys.practitionerRestore(),
mutationFn: (id: string) =>
apiPost<Practitioner>(`${BASE}/${id}/restore`, {}),
onSuccess: invalidate,
});
return { create, update, remove, restore };
}
+2
View File
@@ -95,7 +95,9 @@ export const encounterKeys = {
encounterCreate: () => ["encounters", "create"] as const,
encounterUpdate: () => ["encounters", "update"] as const,
encounterDelete: () => ["encounters", "delete"] as const,
encounterRestore: () => ["encounters", "restore"] as const,
practitionerCreate: () => ["practitioners", "create"] as const,
practitionerUpdate: () => ["practitioners", "update"] as const,
practitionerDelete: () => ["practitioners", "delete"] as const,
practitionerRestore: () => ["practitioners", "restore"] as const,
};