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 (
);
};
/** 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 (
);
};
/** Two dates, stored as a FHIR Period. */
export const PeriodFields: React.FC<{ label: string; context: string; disabled?: boolean }> = ({ label, context, disabled }) => (
);
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 (
);
};
export type ReferenceKind = { key: string; label: string };
const LinkedRecordWindow = React.lazy(() => import('./linkedRecordWindows.tsx'));
// Staff may start any linked kind here except a form response, as the old reference picker allowed.
const canCreateLinked = (kind: string): boolean => ['Condition', 'Observation', 'AllergyIntolerance'].includes(kind);
interface ReferencesFieldProps {
label: string;
context: string;
kinds: ReferenceKind[];
listFor: (kind: string) => FhirResource[];
describe: (resource: FhirResource) => string;
disabled?: boolean;
/** View and create beside each link, for staff. */
linkActions?: boolean;
}
/** Links to other records, stored as `{ referenceType, reference }` rows. */
export const ReferencesField: React.FC = ({ label, context, kinds, listFor, describe, disabled, linkActions = false }) => {
const { watch, setValue, getValues } = useFormContext();
const { createReferenceObject, extractReferenceId } = useFhirServiceContext();
const rows: { referenceType?: string; reference?: Reference }[] = watch(context) ?? [];
const [viewing, setViewing] = useState();
const [creating, setCreating] = useState();
const update = (index: number, row: object) => {
const next = [...(getValues(context) ?? [])];
next[index] = row;
setValue(context, next, { shouldDirty: true });
};
return (
);
};