import { Attachment } from 'fhir/r4'; import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { FileText, ImageIcon } from 'lucide-react'; import './FileAnswer.css'; import { Icon } from '../ui/icon'; import { Spinner } from '../ui/spinner'; import { Text } from '../ui/text'; import { Flex, FlexItem } from '../ui/flex'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { usePhotoUploadService } from '../../services/PhotoUploadService'; import { refusalOr } from '../../utils/serverRefusalMessage'; import { SCAN_HOLD_WORDS, scanHoldOf, type ScanHold } from '../../utils/uploadScanHold'; import { QUESTIONNAIRE_FILE_ACCEPT, describeFileSize, isBrowserRenderableAttachment, questionnaireFileAnswer, questionnaireFileRejection, } from '../../utils/questionnaireFileAnswer'; interface FileAnswerProps { value?: Attachment; onChange: (attachment: Attachment | undefined) => void; /** The question text, so the control says what it is for to somebody using a screen reader. */ label?: string; /** Read-only: show what was attached rather than offering to change it. */ readOnly?: boolean; /** Where the file goes, when it is not a form's answer. */ hint?: string; /** The document request this file answers, so the upload is issued for that request. */ taskId?: string; } /** * A FILE A PATIENT SENDS IN ANSWER TO A QUESTION, and the thing that was missing when a practice asked. * * IT UPLOADS ON SELECTION, NOT AT SUBMIT, and that is the design rather than an implementation * choice. `TaskPage` autosaves the answers on a debounce, so a file held in browser memory until Submit * is lost by the save that was supposed to protect it, and lost again if the patient closes the tab. * The public form does hold its file until submit and is RIGHT to - a visitor there has no account to * upload against - so this must not be built by copying it. Two surfaces, two right answers. * * NOT `PhotoAttachment`, WHICH LOOKS LIKE THE SAME COMPONENT. That one exists to put a picture on a * profile: it refuses anything that is not an image, and it draws the file into a canvas to make a * thumbnail. A PDF cannot go through a canvas at all, and neither can HEIC - the iPhone camera's * default, and therefore the commonest file this control will ever be handed. */ const FileAnswer = ({ value, onChange, label, readOnly = false, hint, taskId }: FileAnswerProps) => { const [error, setError] = useState(); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); const cameraInputRef = useRef(null); const { uploadDocument } = usePhotoUploadService(); const onFileSelected = async (event: ChangeEvent) => { setError(undefined); const file = event.target.files?.[0]; // Clearing the input FIRST, so choosing the same file twice after a failure still fires a change. if (event.target.value) event.target.value = ''; if (!file) return; // REFUSED BEFORE THE NETWORK CALL. The presign would refuse this too, in a sentence written for // a developer; the patient gets one written for them, and no upload is started. const rejection = questionnaireFileRejection(file); if (rejection) { setError(rejection); return; } setUploading(true); try { const { key } = await (taskId ? uploadDocument(file, undefined, { taskId }) : uploadDocument(file)); onChange(questionnaireFileAnswer({ key, file })); } catch (uploadError) { console.error('Failed to attach the file to this answer:', uploadError); // A refused upload says why (a helper whose grant does not cover documents); no device would help it. setError(refusalOr(uploadError, 'That file could not be sent', 'That file could not be sent. Please try again, or attach it from a different device.')); } finally { setUploading(false); } }; if (readOnly) { return ; } return (
{/* Two inputs rather than one with a toggle: `capture` cannot be added and removed on the same element reliably, and a phone decides what it means. The file input takes a photo the patient already has; the camera input opens the camera. */} {value && } {uploading ? ( Sending your file… ) : ( {/* ON A DESKTOP THIS OPENS A FILE PICKER, because `capture` is a hint a browser without a camera ignores. Offering it everywhere is the honest trade: hiding it would need a camera check that cannot be made without asking for permission, and a patient on a phone - which is most of them - is the person this is for. */} {value && ( )} )} {error && ( setError(undefined)}> {error} )} {hint ?? 'A PDF or a photo, up to 10 MB. It goes to your practice with this form.'}
); }; /** * What was attached, shown rather than described. * * THE STAFF SURFACES THAT READ A RESPONSE BACK WRAP IT IN ``, which is `inert` - nothing * inside can be clicked. So this must SHOW the file rather than offer to open it: a download button * there would be a control that cannot be operated, which is the defect this whole card is about. * * A file the browser cannot decode says so instead of rendering a broken image. HEIC is the case: it is * accepted for upload because it is what an iPhone writes, and no major browser can display one. */ const AttachedFile = ({ attachment }: { attachment?: Attachment }) => { const { getPresignedDownloadUrl } = usePhotoUploadService(); const [url, setUrl] = useState(); const [failed, setFailed] = useState(false); const [hold, setHold] = useState(); const key = attachment?.url; const renderable = isBrowserRenderableAttachment(attachment?.contentType); useEffect(() => { if (!key || !renderable) { setUrl(undefined); return; } let live = true; setFailed(false); setHold(undefined); getPresignedDownloadUrl(key) .then((presigned) => { if (live) setUrl(presigned); }) .catch((error: unknown) => { if (!live) return; setHold(scanHoldOf(error)); setFailed(true); }); return () => { live = false; }; }, [key, renderable]); if (!attachment) { return ( No file was attached to this answer. ); } const isPdf = (attachment.contentType ?? '').toLowerCase() === 'application/pdf'; const size = describeFileSize(attachment.size); return (
{attachment.title || 'Attached file'} {size && ( {size} )} {url && !failed && !isPdf && ( {attachment.title setFailed(true)}/> )} {url && !failed && isPdf && ( This PDF cannot be shown here. It is stored with this answer. )} {hold ? ( {SCAN_HOLD_WORDS[hold]} ) : (!renderable || failed) && ( This file cannot be shown in a browser. It is stored with this answer. )}
); }; export default FileAnswer;