import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { FormTokenField } from "../ui/form-token-field"; import { ProgressBar } from "../ui/progress-bar"; import type { TokenItem } from "../ui/form-token-field"; import { DismissibleAlert } from '../ui/dismissible-alert'; import { X } from 'lucide-react'; import { Flex, FlexItem } from '../ui/flex'; import { Text } from '../ui/text'; import { Spacer } from '../ui/spacer'; import { Controller, useFormContext } from 'react-hook-form'; import { IValueSet, useValueSetServiceContext } from '../../services'; import { debounce } from 'lodash'; import { getNestedError } from '../../utils/formUtils.ts'; interface ValueSetSearchProps { label: string; context: string; valueSetUrl: string; multiple?: boolean; required?: boolean; disabled?: boolean; placeholder?: string; helperText?: string; } interface ValueSetOption { code: string; display: string; system?: string; } const FhirValueSetSearch: React.FC = ({ label, context, valueSetUrl, multiple = true, required = false, disabled = false, placeholder = 'Search for codes...', helperText }) => { const { control, formState: { errors } } = useFormContext(); const [searchTerm, setSearchTerm] = useState(''); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); /** * WHY THE LAST SEARCH FOUND NOTHING - a lookup that failed, or a term with no matches. * * Both used to end at `setOptions([])`, so they rendered identically: an empty list and no words. * That is the worst pair to collapse, because they ask the clinician to do opposite things - try a * different word, or tell somebody the lookup is down. * * Measured on the diagnostic report form 2026-08-27: "hemoglobin A1c" offered nothing, and so did * "blood" as a deliberately broad control, with no message either time - while the observation * form's own LOINC search answered the same words. There was no way to tell from the screen * whether the value set was empty, unreachable, or simply not the one that holds those codes. */ const [lookupFailed, setLookupFailed] = useState(false); const { fetchValueSetInternalWithStatus } = useValueSetServiceContext(); const latestRequestRef = useRef(0); const runSearch = useCallback( async (term: string) => { const trimmed = term.trim(); if (trimmed.length < 2) { latestRequestRef.current += 1; setOptions([]); setLoading(false); return; } const requestId = ++latestRequestRef.current; setLoading(true); setLookupFailed(false); try { const result = await fetchValueSetInternalWithStatus(valueSetUrl, false, trimmed); if (requestId !== latestRequestRef.current) { return; } if (result.outcome === 'ok' && Array.isArray(result.results)) { setOptions(result.results); } else { // Unavailable is a failed lookup, not a confirmed empty list. setOptions([]); setLookupFailed(true); } } catch (error) { if (requestId === latestRequestRef.current) { console.error('Error fetching value set:', error); setOptions([]); setLookupFailed(true); } } finally { if (requestId === latestRequestRef.current) { setLoading(false); } } }, [fetchValueSetInternalWithStatus, valueSetUrl] ); const debouncedSearch = useMemo( () => debounce((term: string) => { void runSearch(term); }, 300), [runSearch] ); const handleSearchChange = (term: string) => { setSearchTerm(term); // Retire the in-flight answer at input time; the debounce does not cancel the transport. latestRequestRef.current += 1; debouncedSearch.cancel(); setOptions([]); setLookupFailed(false); if (term.trim().length < 2) { setLoading(false); return; } setLoading(true); debouncedSearch(term); }; useEffect(() => { debouncedSearch.cancel(); setSearchTerm(''); setOptions([]); setLoading(false); latestRequestRef.current += 1; }, [context, valueSetUrl, debouncedSearch]); useEffect(() => { return () => { debouncedSearch.cancel(); }; }, [debouncedSearch]); const suggestions = useMemo(() => { return options.map((option) => option.display); }, [options]); const renderSelectedChips = (selectedValues: IValueSet[], onRemove: (index: number) => void) => { if (!selectedValues || selectedValues.length === 0) { return null; } return ( {selectedValues.map((value, index) => ( ))} ); }; return ( <> { const selectedValues: IValueSet[] = multiple ? (Array.isArray(field.value) ? field.value : []) : (field.value ? [field.value] : []); const handleTokenChange = (tokens: (string | TokenItem)[]) => { const lastToken = tokens[tokens.length - 1]; const selectedDisplay = typeof lastToken === 'string' ? lastToken : lastToken?.value; if (!selectedDisplay) return; const selectedOption = options.find( (option) => option.display === selectedDisplay ); if (!selectedOption) return; if (multiple) { const alreadySelected = selectedValues.some( (value) => value.code === selectedOption.code ); if (alreadySelected) return; field.onChange([...selectedValues, selectedOption]); } else { field.onChange(selectedOption); } setSearchTerm(''); setOptions([]); }; const handleRemoveChip = (index: number) => { if (multiple) { const newSelections = selectedValues.filter((_, i) => i !== index); field.onChange(newSelections.length > 0 ? newSelections : null); } else { field.onChange(null); } }; return ( <> {label} {required && *} {helperText && ( <> {helperText} )} {renderSelectedChips(selectedValues, handleRemoveChip)} {/* THE REF RHF WAS ALREADY REACHING FOR, and could never find. `shouldFocusError` defaults to true and no modal turns it off, so on every refused submit react-hook-form tries to focus the first invalid field. This component forwarded no ref, so there was nothing to focus - and a clinician who clicked Create from below the fold saw NOTHING happen. The required-field message was on the page the whole time, next to a field they could not see. It goes on a wrapper rather than the control: `FormTokenField` is a WordPress component that accepts no ref, and `tabIndex={-1}` makes this focusable programmatically without putting a div into the tab order. Focusing it scrolls it into view, which is the behaviour that draws the eye to the field that actually stopped the submit. */}
{loading && } {/* A SEARCH THAT FOUND NOTHING NOW SAYS WHICH KIND OF NOTHING. Silence here reads as "still typing", so a clinician keeps trying words against a list that will never answer. `aria-live` because the field keeps focus while this appears underneath it - the reader is looking at what they typed, not below it. */} {!loading && searchTerm.trim().length >= 2 && options.length === 0 && ( {lookupFailed ? 'Could not load this list. Changing your search will not help. Contact support if this continues.' : `No matches for “${searchTerm.trim()}”.`} )} ); }} /> {getNestedError(errors, context) && ( {label} is required )} ); }; export default FhirValueSetSearch;