import { useEffect, useRef, useState } from 'react'; import type { KeyboardEvent, ReactNode } from 'react'; import { toast } from 'sonner'; import { FieldHistoryLabel } from './FieldHistoryLabel'; import { useFieldHistory, useFieldHistoryScope } from './FieldHistoryProvider'; import { isEmptyFieldValue } from '../../utils/fieldHistory/isEmptyFieldValue'; import { isRecordChanged, RELOAD_DISCARDS_MESSAGE } from '../../utils/recordChanged'; import { isSaveRetired, isSaveTimedOut } from '../../utils/saveOperation'; import { onViewerChange } from '../../utils/writeThroughHold'; import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../ui/select'; export type EditableFieldEditorType = 'text' | 'email' | 'tel' | 'date' | 'select'; export interface EditableFieldOption { value: string; label: string; } /** The opening a Reload record was asked for. */ export interface ReloadOwner { signal: AbortSignal; belongs: () => boolean; } export interface EditableFieldProps { label: string; /** Read-only display (rich node, like ``). */ value: ReactNode; /** Raw value used to prepopulate the editor -- may differ from the formatted read display. */ editValue: T; /** History drilldown path, forwarded to `FieldHistoryLabel`. */ path?: string; /** Defaults to whether an `onSave` was supplied. */ editable?: boolean; /** `opening`: the `snapshot` as it was when this edit opened - the baseline the save checks against. */ onSave?: (next: T, opening?: unknown) => Promise; /** * The record this field is shown from. Captured when an edit opens and handed to `onSave`, so a save can * tell whether what it edits changed since; a later refresh of this prop never changes the draft. */ snapshot?: unknown; /** * The record an edit belongs to. Defaults to the history scope's record. When it changes while an edit is open - * or the person signed in changes - the edit closes and its draft and baseline are dropped, so a draft opened on * one record is never saved onto another. */ owner?: string; /** * Reload record, after a save was refused (changed elsewhere, out of date, or unconfirmed): reads the record as * it is now and shows it. The edit then closes on that value, and opens again from it. `owner` is the opening * that asked: cancelled or reopened, its `signal` aborts and `belongs()` turns false, so the reload's read is not * sent or shown for it. */ onReload?: (owner: ReloadOwner) => Promise; /** Built-in single-value editor (T = string). */ editorType?: EditableFieldEditorType; /** For `editorType="select"`. */ options?: EditableFieldOption[]; placeholder?: string; /** Custom multi-part editor override -- receives the working draft + a setter; skips the built-in editor. */ renderEditor?: (draft: T, setDraft: (value: T) => void, disabled: boolean) => ReactNode; /** Optional caption shown while editing (e.g. "Patient will receive notice of change"). Omit for no caption. */ editHint?: ReactNode; /** Read-state layout parity with ``. */ variant?: 'default' | 'stacked'; /** * - OPT-IN, AND THE OPT-IN IS THE DESIGN. * * A blank field cannot say whether anybody ever asked, and for a receptionist doing intake those are * different jobs: *we never asked* is a gap to close, *they declined* is settled and asking again is * worse than useless. This lets a field record which. * * **Supplied per field, never switched on for everything.** `EditableField` is shared across ~25 * surfaces and most of them edit things that cannot decline to answer - an organization's name has no * opinion. Absent this prop the component is exactly what it was. * * The RECORD state lives outside: pass the current `reason` and persist through `onRecord`. This * component owns the control, not the vocabulary. */ absence?: { reason?: string; /** The choices, already labelled for a person - `undefined` clears the record. */ options: ReadonlyArray<{ value: string; label: string }>; onRecord: (reason: string | undefined, opening?: unknown) => Promise; }; } /** * The editable sibling of ``: same label/history composition (`FieldHistoryLabel`) and the * same read-state row layout, but the value can be clicked/keyboard-activated into an inline * editor (ported from `PatientProfileCard/EditableField`) with Save/Cancel. Card-agnostic -- * layout comes entirely from utility classes, not `ahr-patient-profile-card__*`. */ export function EditableField({ label, value, editValue, path, editable, onSave, editorType = 'text', options, placeholder, renderEditor, editHint, variant = 'default', absence, snapshot, owner, onReload, }: EditableFieldProps): JSX.Element { const canEdit = editable ?? Boolean(onSave); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(editValue); const [saving, setSaving] = useState(false); const scope = useFieldHistoryScope(); const { refresh } = useFieldHistory(); // While history shows, a save is followed into it rather than waiting for a reload; the follow // ends with this field or when it shows another record. // A save still in flight when the field leaves or shows another record must not start one: the // generation it began under is compared once it lands. const stopFollowing = useRef<(() => void) | undefined>(undefined); const recordGeneration = useRef(0); const followSave = (savedUnder: number) => { if (savedUnder !== recordGeneration.current) return; stopFollowing.current?.(); stopFollowing.current = scope?.id ? refresh?.(scope.resourceType, scope.id, path) : undefined; }; useEffect(() => () => { recordGeneration.current++; stopFollowing.current?.(); }, [scope?.resourceType, scope?.id]); // WHOSE OPENING THIS IS. The draft and its baseline belong to the record (and the person signed in) the edit // was opened for; another record on the same field instance, or another person, closes it. const ownerKey = owner ?? (scope?.id ? `${scope.resourceType}/${scope.id}` : undefined); // Null while nothing is open; otherwise the owner the opening was made for (which may itself be unknown, for a // field outside any record's history scope). const openedFor = useRef<{ owner: string | undefined } | null>(null); // Each opening (and each close) is a new generation; a Reload record belongs to the one that asked for it. const generation = useRef(0); const reloadFor = useRef(); const endOpening = () => { generation.current += 1; reloadFor.current?.abort(); reloadFor.current = undefined; setReloading(false); }; const closeOpening = () => { endOpening(); openedFor.current = null; opened.current = undefined; setRefused(undefined); setEditing(false); }; const closeRef = useRef(closeOpening); closeRef.current = closeOpening; useEffect(() => { if (openedFor.current && openedFor.current.owner !== ownerKey) closeRef.current(); }, [ownerKey]); useEffect(() => onViewerChange(() => closeRef.current()), []); // The baseline this edit is checked against, and what a refused save said. `stale`: this opening can save // nothing more (someone else changed it, the page is out of date, or the result is unknown) - only Reload record. const opened = useRef(undefined); const [refused, setRefused] = useState<{ message: string; stale: boolean }>(); const [reloading, setReloading] = useState(false); const startEdit = () => { if (!canEdit) return; endOpening(); openedFor.current = { owner: ownerKey }; opened.current = snapshot; setRefused(undefined); setDraft(editValue); setEditing(true); }; const cancel = () => { endOpening(); openedFor.current = null; opened.current = undefined; setEditing(false); setRefused(undefined); setDraft(editValue); }; // Someone else saved first, the page is out of date, or the result is unknown: said plainly, and nothing more is // saved from this opening. Nothing is merged over another person's change, and Save is not offered again. const refusedBy = (error: unknown): boolean => { // A save that no longer belongs to this field (another patient, another person signed in) says nothing here. if (isSaveRetired(error)) return true; if (isSaveTimedOut(error)) { setRefused({ message: error.message, stale: error.mayHaveSaved }); return true; } if (!isRecordChanged(error)) return false; setRefused({ message: error.message, stale: true }); return true; }; /** * What is saved now, read fresh and shown; this edit closes on it (the person was told it discards theirs). Only * for the opening that asked: cancelled or reopened meanwhile, nothing of it is sent, shown, closed or said. */ const reload = async () => { const mine = generation.current; const controller = new AbortController(); reloadFor.current = controller; const belongs = () => generation.current === mine; setReloading(true); try { await onReload?.({ signal: controller.signal, belongs }); if (belongs()) closeOpening(); } catch (error) { if (!belongs() || isSaveRetired(error)) return; setRefused({ message: 'The record could not be read. Try Reload record again, or Cancel.', stale: true }); } finally { if (belongs()) setReloading(false); } }; const save = async () => { if (!onSave) return; // An opening that was refused saves nothing more; one made for another record (or before it was closed) is never saved. if (refused?.stale) return; if (!openedFor.current || openedFor.current.owner !== ownerKey) { closeOpening(); return; } setSaving(true); const savedUnder = recordGeneration.current; try { await onSave(draft, opened.current); setRefused(undefined); setEditing(false); followSave(savedUnder); } catch (error) { if (refusedBy(error)) return; console.error(`Failed to update ${label}:`, error); toast.error(`Could not update ${label.toLowerCase()}. Please try again.`); } finally { setSaving(false); } }; /** * Shares `saving` with Save, so the two cannot race - recording an absence and saving a value both * PUT the same resource, and a receptionist clicking one while the other is in flight would produce * whichever landed second. */ const recordAbsence = async (reason: string | undefined) => { if (!absence) return; // An opening that was refused saves nothing more; one made for another record (or before it was closed) is never saved. if (refused?.stale) return; if (!openedFor.current || openedFor.current.owner !== ownerKey) { closeOpening(); return; } setSaving(true); const savedUnder = recordGeneration.current; try { await absence.onRecord(reason, opened.current); setRefused(undefined); setEditing(false); followSave(savedUnder); } catch (error) { if (refusedBy(error)) return; console.error(`Failed to record ${label}:`, error); toast.error(`Could not update ${label.toLowerCase()}. Please try again.`); } finally { setSaving(false); } }; const handleValueKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); startEdit(); } }; const labelClassName = variant === 'stacked' ? 'text-xs font-medium' : 'text-sm font-medium'; if (editing) { return (
{refused ? (

{refused.message}

{refused.stale ?

{RELOAD_DISCARDS_MESSAGE}

: null}
) : null} {renderEditor ? ( renderEditor(draft, setDraft, saving) ) : editorType === 'select' ? ( ) : ( setDraft(event.target.value as unknown as T)} placeholder={placeholder} disabled={saving} autoFocus className="h-9 text-sm" /> )}
{refused?.stale ? ( ) : ( )}
{absence ? ( // BELOW Save/Cancel, deliberately. Entering a value is the ordinary act and stays // first; recording that there is nothing to enter is the exception and reads as one.
Nothing to enter? {absence.options.map((option) => ( ))} {absence.reason ? ( // Clearing must always be reachable. A recorded absence is a statement // about a person, and a receptionist who records the wrong one needs a way // back that is not "type a value you do not have". ) : null}
) : null} {editHint ? (
{editHint}
) : null}
); } const display = isEmptyFieldValue(value) ? ( — ) : ( value ); const valueInteractionProps = canEdit ? { onClick: startEdit, role: 'button' as const, tabIndex: 0, title: 'Click to edit', onKeyDown: handleValueKeyDown, } : {}; if (variant === 'stacked') { return (
{display}
); } return (
{display}
); } export default EditableField;