import React, { useState, useEffect, useMemo, useRef } from 'react'; import { SearchControl } from "../ui/search-control"; import { Spinner } from "../ui/spinner"; import { Item, ItemGroup } from "../ui/item-group"; import { Truncate } from "../ui/truncate"; import { Card, CardBody, CardHeader } from '../ui/wp-card'; import { Flex, FlexItem, FlexBlock } from '../ui/flex'; import { Text } from '../ui/text'; import { Modal } from '../ui/wp-modal-shim'; import { debounce } from 'lodash'; import { ExtendedQuestionnaireItem, useQuestionnaireServiceContext, useValueSetServiceContext } from '../../services'; import type { LoincCatalogueQuestion } from '../../services/ValueSetService'; import { v4 as uuidv4 } from 'uuid'; import { Coding, Questionnaire } from 'fhir/r4'; import { FormProvider, useForm } from 'react-hook-form'; import QuestionnairePreview from '../../pages/DashboardPage/QuestionnairesPage/QuestionnairePage/QuestionnairePreview/QuestionnairePreview.tsx'; import { useNavigate } from 'react-router-dom'; interface LoincSearchProps { label: string; type: 'question' | 'questionnaire' | 'panel'; onSelectItem: (item: any) => void; /** A panel is handed back once loaded, for the caller's own review, instead of opening the preview modal. */ reviewPanels?: boolean; } interface LoincItem { code: string; text?: string; display?: string; datatype?: string | null; answerList?: LoincCatalogueQuestion['answers']; units?: LoincCatalogueQuestion['units']; } // Searching, a whole answer, and a read that failed are three different things to say; a failure is never "no results". type SearchStatus = 'idle' | 'searching' | 'found' | 'unavailable'; const MIN_TERM = 2; const LoincSearch: React.FC = ({ label, type = 'question', onSelectItem, reviewPanels = false }) => { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); const [results, setResults] = useState([]); const [status, setStatus] = useState('idle'); const [loadingPreview, setLoadingPreview] = useState>({}); const [previewFailed, setPreviewFailed] = useState>({}); // Each search and each preview takes a number; an answer that is no longer the latest is dropped. const searchSeq = useRef(0); const previewSeq = useRef(0); const mounted = useRef(true); const [showPreviewModal, setShowPreviewModal] = useState(false); const { searchQuestions, searchQuestionnaires, getLoincPanel, fetchFromLoinc } = useValueSetServiceContext(); const { transformFromFhirQuestionnaire, convertFromLFormsToQuestionnaire, convertLFormsItemsToFHIR, getLoincFhirType, convertUnitsToExtensions } = useQuestionnaireServiceContext(); const methods = useForm({ defaultValues: {} }); const { reset, getValues } = methods; const runSearch = async (term: string) => { const seq = ++searchSeq.current; previewSeq.current += 1; setStatus('searching'); try { const found: LoincItem[] = type === 'question' ? (await searchQuestions(term)).map((question) => ({ code: question.code, text: question.text, datatype: question.datatype, answerList: question.answers, units: question.units, })) : (await searchQuestionnaires(term)).map(({ code, display }) => ({ code, display, text: display })); if (seq !== searchSeq.current || !mounted.current) return; setResults(found); setPreviewFailed({}); setStatus('found'); } catch (error) { if (seq !== searchSeq.current || !mounted.current) return; console.error('LOINC catalogue search failed:', error); setResults([]); setStatus('unavailable'); } }; const latestSearch = useRef(runSearch); latestSearch.current = runSearch; const debouncedSearch = useMemo(() => debounce((term: string) => latestSearch.current(term), 300), []); const handleSearchChange = (value: string) => { setSearchTerm(value); searchSeq.current += 1; previewSeq.current += 1; setResults([]); if (value.trim().length >= MIN_TERM) { setStatus('searching'); debouncedSearch(value); } else { debouncedSearch.cancel(); setStatus('idle'); } }; const retrySearch = () => { debouncedSearch.cancel(); void runSearch(searchTerm); }; const preview = async (event: React.MouseEvent, index: number, code: string) => { event.stopPropagation(); if (type === 'questionnaire') { handleQuestionnairePreview(index, code); } else if (type === 'panel') { handlePanelPreview(index, code); } }; // A preview opens only if it is still the latest one asked for, and a failed one says so on its row. const loadPreview = async (index: number, read: () => Promise): Promise => { const seq = ++previewSeq.current; setLoadingPreview(prev => ({ ...prev, [index]: true })); setPreviewFailed(prev => ({ ...prev, [index]: false })); try { const value = await read(); return seq === previewSeq.current && mounted.current ? value : undefined; } catch (error: any) { console.error(error); if (seq === previewSeq.current && mounted.current) setPreviewFailed(prev => ({ ...prev, [index]: true })); return undefined; } finally { if (mounted.current) setLoadingPreview(prev => ({ ...prev, [index]: false })); } }; const handleQuestionnairePreview = async (index: number, code: string) => { let questionnaire = await loadPreview(index, () => fetchFromLoinc(code)); if (!questionnaire) { return; } if ((questionnaire as { type?: string }).type === 'LOINC') { questionnaire = convertFromLFormsToQuestionnaire(questionnaire) as Questionnaire; } reset({ id: questionnaire.id, ...transformFromFhirQuestionnaire(questionnaire) }); setShowPreviewModal(true); }; const handlePanelPreview = async (index: number, code: string) => { const loincPanel = await loadPreview(index, () => getLoincPanel(code)); if (!loincPanel) { return; } const panel = convertFromLFormsToPanel(loincPanel); panel.item?.forEach((item) => { item.code?.forEach((code: Coding) => { if (!code.system) { code.system = loincPanel.codeSystem; } }); }); const transformed = transformFromFhirQuestionnaire(panel as Questionnaire); if (reviewPanels) { onSelectItem({ type: 'group', item: transformed.item?.[0] as ExtendedQuestionnaireItem }); return; } reset(transformed); setShowPreviewModal(true); }; const convertFromLFormsToPanel = (lformsData: any) => { const panel = { linkId: uuidv4(), text: lformsData.name, type: 'group', code: [ { system: lformsData.codeSystem || 'http://loinc.org', code: lformsData.code, display: lformsData.name } ], item: convertLFormsItemsToFHIR(lformsData.items || []) }; return { title: lformsData.name, copyright: lformsData.copyrightNotice, item: [panel] }; }; const convertLoincQToItem = (loincItem: LoincItem) => { const { code, text, answerList, units, datatype } = loincItem; const item: any = { linkId: uuidv4(), code: [ { code, system: 'http://loinc.org', display: text } ], text: text || '', type: getLoincFhirType(datatype || '', units) }; // Already in LOINC sequence order from the catalogue route. if (answerList && answerList.length > 0) { item.answerOption = answerList.map(answer => ({ value: { code: answer.code ?? undefined, system: 'http://loinc.org', display: answer.display, ...(answer.score != null ? { score: answer.score } : {}) } })); } if (units && units.length > 0 && ['quantity', 'integer', 'decimal'].includes(item.type)) { item.extension = convertUnitsToExtensions(units, item.type); } return item; }; const handleSelectQuestion = (item: LoincItem) => { onSelectItem({ type, item: convertLoincQToItem(item) }); }; const handleImport = () => { setShowPreviewModal(false); const q = getValues() as Questionnaire; if (type === 'panel') { onSelectItem({ type: 'group', item: q.item?.[0] as ExtendedQuestionnaireItem }); } else if (type === 'questionnaire') { const { id } = q; navigate(`../new/${id}`, { relative: 'path', state: { questionnaire: null, template: null } }); } }; useEffect(() => { mounted.current = true; return () => { mounted.current = false; debouncedSearch.cancel(); }; }, [debouncedSearch]); return ( <> {status === 'unavailable' ? ( The LOINC catalogue could not be reached, so nothing was found or added. Your form is unchanged. ) : searchTerm.trim().length >= MIN_TERM ? ( status !== 'found' ? ( ) : ( results.length > 0 ? ( {results.map((item, index) => ( {type === 'questionnaire' ? item.display : item.text} {previewFailed[index] && ( Preview could not be loaded. Try again. )} {type !== 'question' ? ( ) : ( )} ))} ) : ( No results found for "{searchTerm}". ) ) ) : ( {searchTerm.trim() ? 'Type at least two characters to search.' : <>Type a keyword in the search bar above to find {label}.} )} {/* Preview Modal */} {showPreviewModal && ( Import} onRequestClose={() => setShowPreviewModal(false)}>
)} ); }; export default LoincSearch;