import React, { useState, useEffect, useCallback } from 'react'; import { Flex, FlexItem } from '../components/ui/flex'; import { DismissibleAlert } from '../components/ui/dismissible-alert'; import { Modal } from '../components/ui/wp-modal-shim'; import { useForm, FormProvider } from 'react-hook-form'; import { Goal } from 'fhir/r4'; import { useGoalServiceContext, useAuthServiceContext, usePatientServiceContext } from '../services'; import { FhirGoal, FhirGoalDisplay } from '../components'; import { PrintableRegion, PrintRecord } from '../components/ui/print-record'; import { useFhirGoalTargetedBroadcast } from '../hooks'; import { describeWriteFailure } from '../utils/fetchFailureMessage'; import { invalidSubmitMessage } from '../utils/forms/invalidSubmitMessage'; interface GoalModalProps { isOpen: boolean; onClose: () => void; goal?: Goal; onSuccess?: (goal: Goal) => void; onError?: (error: string) => void; title?: string; } // What the open goal says when it changed and could not be read again; the goals list offers Check again. const GOAL_UNCHECKED = 'This goal changed and could not be checked again, so what you see may be out of date. Use Check again on the goals list to read it again.'; export const GoalModal: React.FC = ({ isOpen, onClose, goal, onSuccess, onError, title }) => { const { userRole, USER_ROLES } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); const { transformToFhirGoal, transformFromFhirGoal, createGoal, updateGoal } = useGoalServiceContext(); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const isNew = !goal; // A RELATED PERSON IS A PATIENT VIEWER. This read `userRole === USER_ROLES.PATIENT`, so a // parent acting for a child was treated as staff here while GoalPage and FhirGoal itself // both counted them. It decides which of the two renders below the reader gets, so getting // it wrong now means a proxy in an edit form rather than a proxy looking at a live button. const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes(userRole as string); const modalTitle = title || `${isNew ? 'Create' : 'Edit'} Goal`; const methods = useForm({ defaultValues: { identifier: [], lifecycleStatus: '', achievementStatus: null, category: [], priority: null, description: null, subject: null, startType: 'date', startDate: '', startCodeableConcept: null, target: [], statusDate: '', statusReason: '', //: this key was declared TWICE - `null` here and `[{}]` below the array group - // so the later one silently won. Both are the wrong shape, and the right value is neither. // // `Goal.expressedBy` is a FHIR `Reference` (0..1), but the FORM carries it as an array of // reference-picker rows which `transformToFhirGoal` collapses: it runs the field through // `transformToReferenceCollection` and then writes `transformedExpressedBy[0]` // (GoalService.tsx:163,179) - exactly how `addresses` is handled. So the empty default for // this shape is `[]`, matching `addresses` above, the only sibling fed to the same transform. // // Both former values happened to be inert: `transformToReferenceCollection` returns `[]` for // a non-array (`null`) and filters `{}` out for lacking `reference`/`referenceType`, and the // spread is then guarded on `length !== 0`. Two independent guards, which is why nothing was // ever written wrong and why nobody chased the warning. `[]` keeps that inertness while // being the shape the transform and the form actually expect - `[{}]` would render one blank // picker row the day a control is added for this field. expressedBy: [], addresses: [], note: [], outcomeCode: [], outcomeReference: [] } }); const { handleSubmit, reset } = methods; // A REFUSED CLICK SAYS SO WHERE IT HAPPENED. The button is in the modal header, above a form that // can be several screens tall, and react-hook-form reports each problem inline beside its field - // so somebody scrolled past the first card saw nothing change at all. This fills the error slot // this modal already has, at the top of the form, next to the button they pressed. const onInvalid = (errors: unknown) => setError(invalidSubmitMessage(errors)); const init = useCallback((goal: Goal) => { reset(transformFromFhirGoal(goal)); }, [transformFromFhirGoal, reset]); useFhirGoalTargetedBroadcast({ goal, isOpen, onUpdate: (next) => { init(next); setError((shown) => (shown === GOAL_UNCHECKED ? undefined : shown)); }, onDelete: onClose, onUnavailable: () => setError(GOAL_UNCHECKED) }); useEffect(() => { if (goal) { init(goal); } }, [goal, init]); const onSubmit = async (data: any) => { if (isPatient) return; if (!patientState) { const errorMsg = 'Patient information is missing. Please try again.'; setError(errorMsg); onError?.(errorMsg); return; } setSubmitting(true); setSuccess(undefined); setError(undefined); try { let result: Goal; if (isNew) { result = await createGoal(transformToFhirGoal(data, patientState)); } else { result = await updateGoal({ id: goal?.id, ...transformToFhirGoal(data) }, goal?.meta?.versionId); } const successMsg = `Goal ${isNew ? 'created' : 'updated'} successfully.`; setSuccess(successMsg); onSuccess?.(result); setTimeout(() => { setSuccess(undefined); onClose(); }, 1500); } catch (err: any) { console.error(`Error ${isNew ? 'creating' : 'updating'} goal:`, err); const errorMsg = describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} goal`); setError(errorMsg); onError?.(errorMsg); } finally { setSubmitting(false); } }; if (!isOpen) return null; return ( {/* A reader gets Print where a writer gets Update. A disabled Update on a record somebody is only reading is a control that answers a question they were not asking; Print answers the one they were - how do I get this to the specialist across town. */} {isPatient ? ( ) : ( )} ]} >
{success && ( setSuccess(undefined)} className="mb-4"> {success} )} {error && ( setError(undefined)} className="mb-4"> {error} )}
{/* THE READER GETS THE RECORD, NOT THE FORM SWITCHED OFF. `FhirGoalDisplay` already renders this goal for the history viewer, the reference preview, the patient snapshot and the list - so a patient was the one audience shown the edit form instead of the view written for reading it. The old shape wrapped the form in `inert`, which is measurably worse than it looks: the fields stay in the accessibility tree, so a screen reader still reads them as inputs, and a text range over the subtree returns an EMPTY STRING - a patient could not select or copy their own goal. */} {isPatient && goal ? ( ) : ( )}
); };