From 27a59c29fd6452a3b113ebb890777b37ea533d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:35:33 +0200 Subject: [PATCH] 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. --- .../__tests__/emergency-profile-save.test.tsx | 80 ++++++++++++++++++- .../records/emergency-profile-manager.tsx | 71 ++++++++++++---- .../emergency-profile-route.test.ts | 25 ++++++ 3 files changed, 158 insertions(+), 18 deletions(-) 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}`)}