fix(records): let an emergency select go back to not recorded

The blood type, organ donor and advance directive selects had no empty
option, and the save left out an empty select, so a value once chosen
could be changed but never removed. Each select now offers "Not
recorded", and a select the person changed rides the save, as null when
it was cleared; an untouched one stays out of the request.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:36:15 +02:00
1 parent 1e606834d8
commit 27a59c29fd
3 files changed
+158 -18

No files matched your search

@@ -1,3 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -30,7 +33,14 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { I18nProvider } from "@/lib/i18n/context";
import { EmergencyProfileManager } from "../emergency-profile-manager";
import {
emergencyProfileUpdateSchema,
type EmergencyProfileDto,
} from "@/lib/validations/emergency-profile";
import {
EmergencyProfileManager,
emergencyPatchBody,
} from "../emergency-profile-manager";
describe("<EmergencyProfileManager> Save", () => {
it("stays disabled until a field differs from the stored profile", () => {
@@ -43,3 +53,71 @@ describe("<EmergencyProfileManager> Save", () => {
expect(row).toMatch(/<button[^>]*disabled=""[^>]*>[^]*?Save<\/button>/);
});
});
describe("emergency selects can be taken back to not recorded", () => {
// A blood type, donor status or directive, once chosen, could be changed
// to another value but never cleared: the select offered no empty option,
// and the save left out an empty select, so even a cleared draft would not
// have reached the column.
const stored: EmergencyProfileDto = {
bloodType: "A_POS",
organDonor: "YES",
advanceDirective: null,
contacts: "A contact",
contactsUnreadable: false,
implants: null,
implantsUnreadable: false,
note: null,
noteUnreadable: false,
};
const draft = {
bloodType: "A_POS",
organDonor: "YES",
advanceDirective: "",
contacts: "A contact",
implants: "",
note: "",
};
it("sends a cleared select as null, which clears the column", () => {
expect(
emergencyPatchBody({ ...draft, bloodType: "" }, stored),
).toMatchObject({ bloodType: null });
});
it("leaves an untouched select out of the save", () => {
const body = emergencyPatchBody(draft, stored);
expect(body).not.toHaveProperty("bloodType");
expect(body).not.toHaveProperty("organDonor");
expect(body).not.toHaveProperty("advanceDirective");
});
it("sends a changed select's new value", () => {
expect(
emergencyPatchBody({ ...draft, advanceDirective: "YES" }, stored),
).toMatchObject({ advanceDirective: "YES" });
});
it("the server accepts what the card sends", () => {
const body = emergencyPatchBody(
{ ...draft, bloodType: "", organDonor: "NO" },
stored,
);
const parsed = emergencyProfileUpdateSchema.safeParse(body);
expect(parsed.success).toBe(true);
expect(parsed.data).toMatchObject({ bloodType: null, organDonor: "NO" });
});
it("offers a not-recorded option in every select", () => {
// The options live in the popover, which a static render never opens;
// read from the source that each select carries the option.
const source = readFileSync(
resolve(__dirname, "../emergency-profile-manager.tsx"),
"utf8",
);
expect(source).toMatch(
/<SelectItem value=\{NOT_RECORDED\}>\s*\{t\("records\.emergency\.notRecorded"\)\}/,
);
expect(source).toMatch(/next === NOT_RECORDED \? "" : next/);
});
});
@@ -31,7 +31,7 @@ import {
type EmergencyProfileDto,
} from "@/lib/validations/emergency-profile";
interface Draft {
export interface Draft {
bloodType: string;
organDonor: string;
advanceDirective: string;
@@ -40,6 +40,45 @@ interface Draft {
note: string;
}
/**
* The select value that stands for "not recorded". Radix refuses an empty
* string as an item value, so the cleared state needs a sentinel of its own;
* the draft keeps "" and only the select sees this.
*/
const NOT_RECORDED = "__none__";
type EnumKey = "bloodType" | "organDonor" | "advanceDirective";
const ENUM_KEYS: readonly EnumKey[] = [
"bloodType",
"organDonor",
"advanceDirective",
];
/**
* The PATCH body for a save. A select rides only when it changed, so a save
* leaves a column it did not touch alone; a changed select that is now empty
* rides as `null`, which is what clears it. The free-text fields always ride,
* an emptied one clearing its column server-side.
*
* An empty select used to be omitted always, and the select had no empty
* option to begin with: a blood type, once chosen, could be changed to
* another one but never taken back to "not recorded".
*/
export function emergencyPatchBody(
draft: Draft,
stored: EmergencyProfileDto,
): Record<string, string | null> {
const body: Record<string, string | null> = {};
for (const key of ENUM_KEYS) {
const next = draft[key] || null;
if (next !== (stored[key] ?? null)) body[key] = next;
}
body.contacts = draft.contacts;
body.implants = draft.implants;
body.note = draft.note;
return body;
}
function draftFromDto(dto: EmergencyProfileDto): Draft {
return {
bloodType: dto.bloodType ?? "",
@@ -66,19 +105,10 @@ export function EmergencyProfileManager() {
const save = useMutation({
mutationKey: queryKeys.emergencyProfile(),
mutationFn: (input: Draft) =>
apiPatch<EmergencyProfileDto>("/api/anamnesis/emergency", {
// A select left at "" is "not recorded" and is omitted so a partial
// save leaves the column untouched; the free-text fields always ride,
// with an emptied field clearing its column server-side.
...(input.bloodType ? { bloodType: input.bloodType } : {}),
...(input.organDonor ? { organDonor: input.organDonor } : {}),
...(input.advanceDirective
? { advanceDirective: input.advanceDirective }
: {}),
contacts: input.contacts,
implants: input.implants,
note: input.note,
}),
apiPatch<EmergencyProfileDto>(
"/api/anamnesis/emergency",
emergencyPatchBody(input, query.data!),
),
onSuccess: (data) => {
setDraft(draftFromDto(data));
queryClient.setQueryData(queryKeys.emergencyProfile(), data);
@@ -110,7 +140,7 @@ export function EmergencyProfileManager() {
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
const enumField = (
key: "bloodType" | "organDonor" | "advanceDirective",
key: EnumKey,
values: readonly string[],
valuePrefix: string,
) => (
@@ -119,14 +149,21 @@ export function EmergencyProfileManager() {
{t(`records.emergency.${key}`)}
</Label>
<Select
value={current?.[key] ?? ""}
value={current?.[key] || NOT_RECORDED}
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
onValueChange={(next) =>
set({ [key]: next === NOT_RECORDED ? "" : next } as Partial<Draft>)
}
>
<SelectTrigger id={`emergency-${key}`} className="w-full">
<SelectValue placeholder={t("records.emergency.notRecorded")} />
</SelectTrigger>
<SelectContent>
{/* The way back to "not recorded", as every optional select in the
records offers one. */}
<SelectItem value={NOT_RECORDED}>
{t("records.emergency.notRecorded")}
</SelectItem>
{values.map((option) => (
<SelectItem key={option} value={option}>
{t(`records.emergency.${valuePrefix}.${option}`)}
@@ -133,4 +133,29 @@ describe("emergency profile route — the write lands in the row", () => {
});
expect(row.emergencyContactsEncrypted).toBeNull();
});
it("a select sent as null goes back to not recorded and leaves the others", async () => {
// The card sends a cleared select as `null` (and leaves an untouched one
// out). Until it did, a recorded blood type could be changed but never
// removed.
await loginAs(USER_ID);
const { GET, PATCH } = await import("@/app/api/anamnesis/emergency/route");
await PATCH(patch({ bloodType: "A_POS", organDonor: "YES" }));
const cleared = await PATCH(patch({ bloodType: null }));
expect(cleared.status).toBe(200);
const row = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: USER_ID },
select: { emergencyBloodType: true, organDonorStatus: true },
});
expect(row.emergencyBloodType).toBeNull();
expect(row.organDonorStatus).toBe("YES");
const body = (await (await GET()).json()) as {
data: { bloodType: string | null; organDonor: string | null };
};
expect(body.data.bloodType).toBeNull();
expect(body.data.organDonor).toBe("YES");
});
});