import { useRef, useState } from "react"; import type { Patient } from "fhir/r4"; import { usePatientServiceContext } from "../../services/PatientService"; import { saveOverCurrent } from "../../utils/saveOverCurrent"; import { olderThan } from "../../utils/versionOrder"; import { isRecordChanged } from "../../utils/recordChanged"; import { isSaveRetired, isSaveTimedOut, useSaveScope, type SaveOperation } from "../../utils/saveOperation"; import { describeWriteFailure, ownFailureSentence } from "../../utils/fetchFailureMessage"; import { sectionChangedSince } from "./sectionBaseline"; /** A section's save: the conditional write, admitted by the save it belongs to before anything is installed. */ export type SectionUpdatePatient = (patient: Patient, basedOn: string, operation: SaveOperation) => Promise; /** The record a draft was filled from, and the draft as filled: what the save's change is built against. */ export type SectionOpening = { record: Patient; draft: D }; interface SectionEditorOptions { patient: Patient | undefined; /** What this section edits, compared against the record as it is now before anything is written. */ pick: (patient: Patient) => unknown; /** The form, filled from a record. */ draftOf: (patient: Patient) => D; /** Only this section's change, made on a copy of the record as it is now. */ apply: (next: Patient, draft: D, opening: SectionOpening) => Patient; updatePatient: SectionUpdatePatient; /** A newer record this section read on Reload record, so the page shows it too. */ onSeen?: (patient: Patient) => void; /** "Your contact details were not saved", and the like. */ failure: string; } const RELOAD_FAILED = "The record could not be read. Try Reload record again, or Cancel."; /** * One profile section's edit, from Edit to the answer it shows. * * The opening (the record and draft it was filled from) belongs to the patient on screen and the person signed * in: when either changes, the edit closes and anything it started is retired, so a draft opened on one patient * is never saved onto another, and A -> B -> A does not bring it back. * * A save is a narrow command: it reads the record as it is now, checks that this section is still what the opening * was filled from, writes only this section's change onto that record, conditional on its version, and re-checks a * lost race the same way. If this section changed elsewhere, the page is out of date, or the result cannot be * confirmed, nothing more can be saved from this opening: the person reloads the record (which discards the draft, * and says so) or cancels. Nothing is merged or retried over another person's change to this section. */ export const useSectionEditor = ({ patient, pick, draftOf, apply, updatePatient, onSeen, failure }: SectionEditorOptions) => { const { fetchPatient } = usePatientServiceContext(); // The patient as shown NOW, not as rendered when a reload began: its version is a floor no reload goes below. const shown = useRef(patient); shown.current = patient; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(() => (patient ? draftOf(patient) : (undefined as D))); const [opening, setOpening] = useState>(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // Refused, out of date, or of unknown outcome: this opening saves nothing more. const [stale, setStale] = useState(false); const [reloading, setReloading] = useState(false); const close = () => { setEditing(false); setSaving(false); setReloading(false); setOpening(undefined); setStale(false); setError(null); }; const scope = useSaveScope(patient?.id, close); const openFrom = (record: Patient) => { scope.renew(); const filled = draftOf(record); setDraft(filled); setOpening({ record, draft: filled }); setStale(false); setSaving(false); setReloading(false); setError(null); setEditing(true); }; const open = () => { if (patient) openFrom(patient); }; const cancel = () => { scope.renew(); close(); }; const save = async () => { if (!opening || stale || !opening.record.id || opening.record.id !== patient?.id) return; const id = opening.record.id; const operation = scope.start(); let retired = false; setSaving(true); setError(null); try { await saveOverCurrent({ operation, read: async () => { const latest = await fetchPatient(id, false, undefined, true, operation.transport); if (!latest) throw new Error("This record could not be read."); return latest; }, stillHolds: (current) => !sectionChangedSince(opening.record, current, pick), apply: (next) => apply(next, draft, opening), write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation), }); close(); } catch (saveError) { if (isSaveRetired(saveError)) { retired = true; return; } if (isSaveTimedOut(saveError)) { setError(ownFailureSentence(saveError)); if (saveError.mayHaveSaved) setStale(true); return; } if (isRecordChanged(saveError)) { setError(ownFailureSentence(saveError)); setStale(true); return; } setError(describeWriteFailure(saveError, failure)); } finally { if (!retired) setSaving(false); } }; /** What is saved now, read fresh, becomes a new opening; the draft is discarded (RELOAD_DISCARDS_MESSAGE says so first). */ const reload = async () => { const id = opening?.record.id; if (!id || id !== patient?.id) return; const operation = scope.start(); setReloading(true); try { const latest = await operation.step(() => fetchPatient(id, false, undefined, true, operation.transport)); if (!latest) throw new Error("This record could not be read."); operation.admit(); onSeen?.(latest); // A read can lag: never an older version than the page shows now, or than this opening was made on. const floor = [shown.current, opening?.record].reduce( (newest, known) => (known && known.id === newest.id && olderThan(newest, known) ? known : newest), latest, ); openFrom(floor); } catch (readError) { if (isSaveRetired(readError)) return; setError(RELOAD_FAILED); } finally { if (operation.belongs()) setReloading(false); } }; return { editing, draft, setDraft, opening, saving, error, stale, reloading, open, cancel, save, reload }; };