import React, { Suspense, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { debounce } from 'lodash'; import { ExternalLink, Plus, Trash2, X } from 'lucide-react'; import type { FhirResource, Reference } from 'fhir/r4'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Textarea } from '../ui/textarea'; import { Checkbox } from '../ui/checkbox'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '../ui/select'; import { IValueSet, useAuthServiceContext, useFhirServiceContext, useValueSetServiceContext } from '../../services'; import { formatFhirHumanName } from '../../utils/fhirHumanName.tsx'; import { getNestedError } from '../../utils/formUtils.ts'; // The relative form's controls, built on the app's own ui primitives rather than WordPress-shaped adapters. export const sentenceCase = (text: string): string => (text ? text.charAt(0).toUpperCase() + text.slice(1) : text); export type ChoiceOption = { value: string; label: string }; export type ChoiceGroup = { label?: string; options: ChoiceOption[] }; const FieldError: React.FC<{ show: boolean; children: React.ReactNode }> = ({ show, children }) => show ?

{children}

: null; interface ChoiceProps { label: string; value: string; onChange: (value: string) => void; groups: ChoiceGroup[]; placeholder?: string; required?: boolean; disabled?: boolean; invalid?: boolean; focusRef?: React.Ref; } /** A labelled single choice. */ export const Choice: React.FC = ({ label, value, onChange, groups, placeholder = 'Choose one', required, disabled, invalid, focusRef }) => { const id = useId(); return (
{/* Radix reports "" when a value arrives before its option does (a reset on load); nothing here clears. */}
); }; interface CodedChoiceProps { label: string; context: string; options: IValueSet[]; /** Patient words for a code, where the value set's own display is FHIR jargon. */ words?: Record; /** Codes offered first, under "Most common". */ common?: string[]; required?: boolean; disabled?: boolean; /** Store the bare code rather than the coding. */ asCode?: boolean; } /** A value-set choice bound to the form, storing the coding (or the bare code). */ export const CodedChoice: React.FC = ({ label, context, options, words, common, required, disabled, asCode }) => { const { control, formState: { errors } } = useFormContext(); const invalid = Boolean(getNestedError(errors, context)); const groups = useMemo((): ChoiceGroup[] => { const toOption = (option: IValueSet): ChoiceOption => ({ value: option.code, label: words?.[option.code] ?? sentenceCase(option.display || option.code), }); if (!common?.length) return [{ options: options.map(toOption) }]; const first = common.map((code) => options.find((option) => option.code === code)).filter((o): o is IValueSet => Boolean(o)); const rest = options.filter((option) => !common.includes(option.code)); return [ { label: 'Most common', options: first.map(toOption) }, { label: 'Other relatives', options: rest.map(toOption) }, ]; }, [options, words, common]); return ( <> ( field.onChange(asCode ? code : options.find((option) => option.code === code) ?? null)} groups={groups} required={required} disabled={disabled} invalid={invalid} focusRef={field.ref}/> )}/> {label} is required ); }; interface TextFieldProps { label: string; context: string; type?: 'text' | 'date' | 'number'; placeholder?: string; disabled?: boolean; } /** A labelled text, date or number input bound to the form. */ export const TextField: React.FC = ({ label, context, type = 'text', placeholder, disabled }) => { const { control } = useFormContext(); const id = useId(); return ( (
field.onChange(event.target.value)}/>
)}/> ); }; interface CheckFieldProps { label: string; context: string; disabled?: boolean; } /** A labelled yes/no bound to the form. */ export const CheckField: React.FC = ({ label, context, disabled }) => { const { control } = useFormContext(); const id = useId(); return ( (
field.onChange(checked === true)}/>
)}/> ); }; /** An age: a number and its unit, stored as `{ value, unit }`. */ export const AgeFields: React.FC<{ label: string; context: string; disabled?: boolean }> = ({ label, context, disabled }) => { const { watch, setValue } = useFormContext(); const { ageUnits } = useValueSetServiceContext(); const id = useId(); const age = watch(context) ?? {}; const unit = ageUnits.find((option: IValueSet) => option.code === age.unit || option.display === age.unit)?.display ?? 'years'; return (
setValue(context, { ...age, value: event.target.value === '' ? undefined : Number(event.target.value), unit, }, { shouldDirty: true })}/>
setValue(context, { ...age, unit: next }, { shouldDirty: true })} groups={[{ options: ageUnits.map((option: IValueSet) => ({ value: option.display, label: option.display })) }]} disabled={disabled}/>
); }; /** An age range in years, stored as a FHIR Range. */ export const RangeFields: React.FC<{ label: string; context: string; disabled?: boolean }> = ({ label, context, disabled }) => { const { watch, setValue } = useFormContext(); const { ucumSystemUrl } = useValueSetServiceContext(); const lowId = useId(); const highId = useId(); const range = watch(context) ?? {}; const set = (end: 'low' | 'high', raw: string) => setValue(context, { ...range, [end]: { ...range[end], system: ucumSystemUrl, value: raw === '' ? undefined : Number(raw) }, }, { shouldDirty: true }); return (
{label}
set('low', event.target.value)}/>
set('high', event.target.value)}/>
); }; /** Two dates, stored as a FHIR Period. */ export const PeriodFields: React.FC<{ label: string; context: string; disabled?: boolean }> = ({ label, context, disabled }) => (
{label}
); interface CodeSearchProps { label: string; context: string; valueSetUrl: string; placeholder?: string; multiple?: boolean; required?: boolean; disabled?: boolean; } /** A terminology search: type two letters, pick a match. Stores a coding, or a list of them. */ export const CodeSearch: React.FC = ({ label, context, valueSetUrl, placeholder, multiple = false, required, disabled }) => { const { control, formState: { errors } } = useFormContext(); const { fetchValueSetInternal } = useValueSetServiceContext(); const id = useId(); const [term, setTerm] = useState(''); const [matches, setMatches] = useState([]); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const latest = useRef(0); const invalid = Boolean(getNestedError(errors, context)); const search = useMemo(() => debounce(async (text: string) => { const trimmed = text.trim(); if (trimmed.length < 2) { setMatches([]); setLoading(false); return; } const request = ++latest.current; setLoading(true); setFailed(false); try { const found = await fetchValueSetInternal(valueSetUrl, false, trimmed); if (request !== latest.current) return; setMatches(Array.isArray(found) ? found.slice(0, 15) : []); setFailed(!Array.isArray(found)); } catch { if (request === latest.current) { setMatches([]); setFailed(true); } } finally { if (request === latest.current) setLoading(false); } }, 300), [fetchValueSetInternal, valueSetUrl]); useEffect(() => () => search.cancel(), [search]); return ( { const chosen: IValueSet[] = multiple ? (Array.isArray(field.value) ? field.value : []) : (field.value?.code || field.value?.display ? [field.value] : []); const pick = (option: IValueSet) => { if (multiple) { if (!chosen.some((value) => value.code === option.code)) field.onChange([...chosen, option]); } else { field.onChange(option); } setTerm(''); setMatches([]); }; const remove = (index: number) => { if (!multiple) return field.onChange(null); const rest = chosen.filter((_, i) => i !== index); field.onChange(rest.length ? rest : null); }; return (
{chosen.length > 0 && (
    {chosen.map((value, index) => (
  • {sentenceCase(value.display || value.code)}
  • ))}
)} {(multiple || chosen.length === 0) && ( { setTerm(event.target.value); void search(event.target.value); }}/> )} {matches.length > 0 && (
    {matches.map((option) => (
  • ))}
)} {loading &&

Searching…

} {!loading && term.trim().length >= 2 && matches.length === 0 && (

{failed ? 'Could not load this list. Contact support if this continues.' : `No matches for “${term.trim()}”.`}

)} {label} is required
); }}/> ); }; /** Free-text notes, stored as FHIR Annotations signed by whoever is signed in. */ export const NotesField: React.FC<{ label: string; context: string; disabled?: boolean }> = ({ label, context, disabled }) => { const { watch, setValue, getValues } = useFormContext(); const { currentUser } = useAuthServiceContext(); const { createReferenceObject } = useFhirServiceContext(); const notes: { text?: string }[] = watch(context) ?? []; const add = () => setValue(context, [...(getValues(context) ?? []), { text: '', time: new Date().toISOString(), authorReference: createReferenceObject(currentUser?.fhir?.resourceType as string, currentUser?.fhir?.id as string, formatFhirHumanName(currentUser?.fhir?.name)), }], { shouldDirty: true }); const remove = (index: number) => setValue(context, (getValues(context) ?? []).filter((_: unknown, i: number) => i !== index), { shouldDirty: true }); return (
{label} {notes.map((note, index) => (