diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx index 6640644ce..845175c56 100644 --- a/src/components/records/__tests__/emergency-profile-save.test.tsx +++ b/src/components/records/__tests__/emergency-profile-save.test.tsx @@ -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(" Save", () => { it("stays disabled until a field differs from the stored profile", () => { @@ -43,3 +53,71 @@ describe(" Save", () => { expect(row).toMatch(/]*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( + /\s*\{t\("records\.emergency\.notRecorded"\)\}/, + ); + expect(source).toMatch(/next === NOT_RECORDED \? "" : next/); + }); +}); diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 920fe4b40..4210900d3 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -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 { + const body: Record = {}; + 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("/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( + "/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}`)}