mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
f761500247
commit
d204b4344b
18 files changed
+259
-46
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user